feat(platform): pagination, admission control, cohort roles, version endpoint, structured assessment editor
- Real server-side pagination (platform/api/src/pagination.js) on the audit log (was a flat LIMIT 200), student attempt history (was LIMIT 100), and assessment session history (was fully unbounded - the one genuine growth risk found). Frontend updated (AuditView, AssessmentsView, StudentsView). - Admission control: resource-policy.yaml's new (disabled-by-default) admission_control.max_concurrent_labs, checked before touching Docker at all, returns a clean 503 CAPACITY_EXCEEDED instead of failing mid-provision. Relayed as a clean student-facing message, never a raw 500. New GET /capacity endpoint feeds the instructor dashboard. - cohort_instructors.role_in_cohort (existed since Phase 2B, never read) is now enforced: teacher vs viewer, rbac.js's requireCohortWriteAccess/requireStudentCohortWriteAccess. - GET /api/version - app version, deployed Git SHA, latest migration as a schema-version stand-in. - Structured assessment authoring UI (AssessmentsView.jsx's PoolSpecEditor) replaces the raw-JSON pool_spec textarea, backed by a new GET /api/admin/scenario-catalog endpoint. Owner-only raw-JSON fallback remains for anything the structured editor doesn't cover. - NEEDS_ATTENTION_FAIL_THRESHOLD/STALE_DAYS moved to env-var configuration. Full regression suite (e2e-phase2a/2b/3a/3b, 67/67) re-run clean after these changes; frontend production build confirmed clean.
This commit is contained in:
@@ -7,9 +7,13 @@
|
||||
// can do, per the Phase 3B RBAC matrix.
|
||||
|
||||
const { computeCurriculumState } = require('./curriculum-rules');
|
||||
const { parsePagination, paginatedResponse } = require('./pagination'); // Phase 4.1 §16
|
||||
|
||||
const NEEDS_ATTENTION_FAIL_THRESHOLD = 3; // repeated validation failures on one scenario
|
||||
const NEEDS_ATTENTION_STALE_DAYS = 7; // no progress in this many days
|
||||
// Phase 4.1 §17 — configurable via env (environment-backed policy, matching the pattern
|
||||
// sessions.js already uses for TTLs), not a database-backed settings table — that's more
|
||||
// machinery than two numbers need. Sensible defaults if unset.
|
||||
const NEEDS_ATTENTION_FAIL_THRESHOLD = Number(process.env.NEEDS_ATTENTION_FAIL_THRESHOLD || 3); // repeated validation failures on one scenario
|
||||
const NEEDS_ATTENTION_STALE_DAYS = Number(process.env.NEEDS_ATTENTION_STALE_DAYS || 7); // no progress in this many days
|
||||
|
||||
module.exports = function registerAdminLearningRoutes(app, deps) {
|
||||
const { instructorSession, rbac, curriculum, getPool, loadProgress, recordAudit } = deps;
|
||||
@@ -70,9 +74,13 @@ module.exports = function registerAdminLearningRoutes(app, deps) {
|
||||
FROM scenario_attempts WHERE student_id = $1 ORDER BY started_at DESC NULLS LAST LIMIT 100`,
|
||||
[req.params.id]
|
||||
);
|
||||
// Phase 4.1 §16 — this is a per-student summary embedded in a wider dashboard view, not a
|
||||
// standalone history list (that's /api/admin/assessments/:id/sessions, which got real
|
||||
// pagination above) — a bounded LIMIT is the appropriate fix here, not a second paginator
|
||||
// inside an already-composite response.
|
||||
const { rows: assessmentSessions } = await pool.query(
|
||||
`SELECT assessment_session_id, assessment_definition_id, type, title, status, started_at, submitted_at, result
|
||||
FROM assessment_sessions WHERE student_id = $1 ORDER BY created_at DESC`,
|
||||
FROM assessment_sessions WHERE student_id = $1 ORDER BY created_at DESC LIMIT 50`,
|
||||
[req.params.id]
|
||||
);
|
||||
res.json({
|
||||
@@ -152,6 +160,21 @@ module.exports = function registerAdminLearningRoutes(app, deps) {
|
||||
res.json(signals);
|
||||
});
|
||||
|
||||
// Phase 4.1 §18 — the scenario catalog (stage -> scenario id/title, no student-specific
|
||||
// status) the structured assessment editor picks from, instead of an instructor hand-typing
|
||||
// scenario ids into raw JSON. Same source data as the per-student progress view
|
||||
// (admin-routes.js's /api/admin/students/:id/progress), just without the progress overlay.
|
||||
admin.get('/api/admin/scenario-catalog', requireAnyInstructor, async (req, res) => {
|
||||
const path = curriculum.loadCurriculumPath();
|
||||
const scenarios = curriculum.loadScenarios();
|
||||
if (!path) return res.json({ stages: [] });
|
||||
const stages = path.stages.map((stage) => ({
|
||||
id: stage.id, name: stage.name,
|
||||
scenarios: stage.scenario_ids.map((sid) => ({ id: sid, title: scenarios.find((s) => s.id === sid)?.title || sid })),
|
||||
}));
|
||||
res.json({ stages });
|
||||
});
|
||||
|
||||
// ── Assessment definitions ───────────────────────────────────────────────────────────────
|
||||
admin.get('/api/admin/assessments', requireAnyInstructor, async (req, res) => {
|
||||
const { rows } = await getPool().query(`SELECT * FROM assessment_definitions ORDER BY type, title`);
|
||||
@@ -231,21 +254,32 @@ module.exports = function registerAdminLearningRoutes(app, deps) {
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// Phase 4.1 §16 — was fully unbounded (no LIMIT at all — the one endpoint here that could
|
||||
// genuinely grow into thousands of rows over a semester of exam sessions). Now real
|
||||
// pagination, same shape as audit-events/student-attempts.
|
||||
admin.get('/api/admin/assessments/:id/sessions', requireAnyInstructor, async (req, res) => {
|
||||
const pool = getPool();
|
||||
const pg = parsePagination(req);
|
||||
let scopeFilter = '';
|
||||
const params = [req.params.id];
|
||||
if (req.instructor.role === 'instructor') {
|
||||
scopeFilter = `AND s.cohort_id IN (SELECT cohort_id FROM cohort_instructors WHERE instructor_id = $2)`;
|
||||
params.push(req.instructor.instructor_id);
|
||||
}
|
||||
const { rows } = await pool.query(
|
||||
`SELECT s.assessment_session_id, s.student_id, st.display_name, s.status, s.started_at, s.submitted_at, s.expires_at, s.result
|
||||
FROM assessment_sessions s JOIN students st ON st.student_id = s.student_id
|
||||
WHERE s.assessment_definition_id = $1 ${scopeFilter} ORDER BY s.created_at DESC`,
|
||||
params
|
||||
);
|
||||
res.json(rows);
|
||||
const pageParams = [...params, pg.pageSize, pg.offset];
|
||||
const [{ rows }, { rows: countRows }] = await Promise.all([
|
||||
pool.query(
|
||||
`SELECT s.assessment_session_id, s.student_id, st.display_name, s.status, s.started_at, s.submitted_at, s.expires_at, s.result
|
||||
FROM assessment_sessions s JOIN students st ON st.student_id = s.student_id
|
||||
WHERE s.assessment_definition_id = $1 ${scopeFilter} ORDER BY s.created_at DESC LIMIT $${params.length + 1} OFFSET $${params.length + 2}`,
|
||||
pageParams
|
||||
),
|
||||
pool.query(
|
||||
`SELECT count(*)::int AS n FROM assessment_sessions s WHERE s.assessment_definition_id = $1 ${scopeFilter}`,
|
||||
params
|
||||
),
|
||||
]);
|
||||
res.json(paginatedResponse(rows, countRows[0].n, pg));
|
||||
});
|
||||
|
||||
admin.post('/api/admin/assessment-sessions/:sessionId/cancel', requireAnyInstructor, async (req, res) => {
|
||||
|
||||
@@ -14,10 +14,15 @@
|
||||
// destroy, events) — the Admin UI never talks to the Provisioner directly.
|
||||
|
||||
const { listInstructors, createInstructor, assertNotLastOwner } = require('./instructors');
|
||||
const attemptsLib = require('./attempts'); // Phase 4.1 §13 — abandonment on instructor-driven reset/destroy
|
||||
const { parsePagination, paginatedResponse } = require('./pagination'); // Phase 4.1 §16
|
||||
|
||||
module.exports = function registerAdminRoutes(app, deps) {
|
||||
const { instructorSession, rbac, provisioner, curriculum, getPool, loadProgress, recordAudit } = deps;
|
||||
const { requireOwner, requireAdminOrOwner, requireAnyInstructor, requireCohortAccess, requireStudentCohortAccess } = rbac;
|
||||
const {
|
||||
requireOwner, requireAdminOrOwner, requireAnyInstructor, requireCohortAccess, requireStudentCohortAccess,
|
||||
requireCohortWriteAccess, requireStudentCohortWriteAccess,
|
||||
} = rbac;
|
||||
|
||||
// Tiny helper: every admin.<verb> call goes through instructorSession first, then whatever
|
||||
// role/scope middleware is passed, then the handler (last function in the list — auto-wrapped
|
||||
@@ -71,6 +76,9 @@ module.exports = function registerAdminRoutes(app, deps) {
|
||||
`SELECT student_id, lab_host_id, status, error_reason, started_at
|
||||
FROM environments WHERE status = 'error' ORDER BY started_at DESC NULLS LAST LIMIT 10`
|
||||
);
|
||||
// Phase 4.1 §26 — best-effort: a Provisioner hiccup here shouldn't break the whole
|
||||
// dashboard, capacity is just omitted (null) rather than the request failing.
|
||||
const capacity = await provisioner.capacity().then((r) => r.status === 200 ? r.body : null).catch(() => null);
|
||||
res.json({
|
||||
active_cohorts: cohorts.rows[0].n,
|
||||
total_students: students.rows[0].n,
|
||||
@@ -80,6 +88,7 @@ module.exports = function registerAdminRoutes(app, deps) {
|
||||
error: envCounts.error || 0,
|
||||
idle_or_destroyed: envCounts.destroyed || 0,
|
||||
},
|
||||
capacity, // { current, max } — max is null when admission control is unconfigured (see resource-policy.yaml)
|
||||
recent_completions: recentCompletions.rows,
|
||||
recent_failures: scoped ? [] : recentFailures.rows, // env-level failures aren't cohort-scoped data; keep them admin/owner-only
|
||||
});
|
||||
@@ -143,11 +152,15 @@ module.exports = function registerAdminRoutes(app, deps) {
|
||||
res.json({ stages });
|
||||
});
|
||||
|
||||
// Phase 4.1 §16 — was a flat LIMIT 100; now real pagination (see pagination.js).
|
||||
admin.get('/api/admin/students/:id/attempts', requireAnyInstructor, requireStudentCohortAccess('id'), async (req, res) => {
|
||||
const { rows } = await getPool().query(
|
||||
`SELECT * FROM scenario_attempts WHERE student_id = $1 ORDER BY created_at DESC LIMIT 100`, [req.params.id]
|
||||
);
|
||||
res.json(rows);
|
||||
const pool = getPool();
|
||||
const pg = parsePagination(req);
|
||||
const [{ rows }, { rows: countRows }] = await Promise.all([
|
||||
pool.query(`SELECT * FROM scenario_attempts WHERE student_id = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3`, [req.params.id, pg.pageSize, pg.offset]),
|
||||
pool.query(`SELECT count(*)::int AS n FROM scenario_attempts WHERE student_id = $1`, [req.params.id]),
|
||||
]);
|
||||
res.json(paginatedResponse(rows, countRows[0].n, pg));
|
||||
});
|
||||
|
||||
admin.get('/api/admin/students/:id/environment', requireAnyInstructor, requireStudentCohortAccess('id'), async (req, res) => {
|
||||
@@ -156,7 +169,7 @@ module.exports = function registerAdminRoutes(app, deps) {
|
||||
res.json(body);
|
||||
});
|
||||
|
||||
admin.post('/api/admin/students/:id/environment/provision', requireAnyInstructor, requireStudentCohortAccess('id'), async (req, res) => {
|
||||
admin.post('/api/admin/students/:id/environment/provision', requireAnyInstructor, requireStudentCohortWriteAccess('id'), async (req, res) => {
|
||||
const { status, body } = await provisioner.provision(req.params.id);
|
||||
res.status(status === 409 ? 200 : status).json(body);
|
||||
});
|
||||
@@ -236,6 +249,12 @@ module.exports = function registerAdminRoutes(app, deps) {
|
||||
admin.post('/api/admin/cohorts/:id/instructors', requireAdminOrOwner, async (req, res) => {
|
||||
const { instructor_id, role_in_cohort } = req.body || {};
|
||||
if (!instructor_id) return res.status(400).json({ error: 'instructor_id required' });
|
||||
// Phase 4.1 §19 — role_in_cohort is now actually enforced (rbac.js's
|
||||
// requireCohortWriteAccess/requireStudentCohortWriteAccess), so it's validated here rather
|
||||
// than accepting any string that would silently never match either check.
|
||||
if (role_in_cohort && !['teacher', 'viewer'].includes(role_in_cohort)) {
|
||||
return res.status(400).json({ error: 'role_in_cohort must be teacher or viewer' });
|
||||
}
|
||||
await getPool().query(
|
||||
`INSERT INTO cohort_instructors (cohort_id, instructor_id, role_in_cohort) VALUES ($1, $2, $3)
|
||||
ON CONFLICT (cohort_id, instructor_id) DO UPDATE SET role_in_cohort = EXCLUDED.role_in_cohort`,
|
||||
@@ -286,7 +305,7 @@ module.exports = function registerAdminRoutes(app, deps) {
|
||||
});
|
||||
|
||||
// ── Classroom prep: Prepare Labs / Destroy Cohort Labs ──────────────────────────────────
|
||||
admin.post('/api/admin/cohorts/:id/prepare-labs', requireAnyInstructor, requireCohortAccess('id'), async (req, res) => {
|
||||
admin.post('/api/admin/cohorts/:id/prepare-labs', requireAnyInstructor, requireCohortWriteAccess('id'), async (req, res) => {
|
||||
const { rows: students } = await getPool().query(
|
||||
`SELECT s.student_id, s.display_name FROM cohort_enrollments e JOIN students s ON s.student_id = e.student_id
|
||||
WHERE e.cohort_id = $1 AND e.enrollment_status = 'enrolled' AND s.status = 'active'`,
|
||||
@@ -306,7 +325,7 @@ module.exports = function registerAdminRoutes(app, deps) {
|
||||
});
|
||||
});
|
||||
|
||||
admin.post('/api/admin/cohorts/:id/destroy-labs', requireAnyInstructor, requireCohortAccess('id'), async (req, res) => {
|
||||
admin.post('/api/admin/cohorts/:id/destroy-labs', requireAnyInstructor, requireCohortWriteAccess('id'), async (req, res) => {
|
||||
const { rows: students } = await getPool().query(
|
||||
`SELECT student_id FROM cohort_enrollments WHERE cohort_id = $1 AND enrollment_status = 'enrolled'`,
|
||||
[req.params.id]
|
||||
@@ -315,6 +334,9 @@ module.exports = function registerAdminRoutes(app, deps) {
|
||||
const { body } = await provisioner.listForStudent(s.student_id);
|
||||
const active = body?.find((e) => e.status !== 'destroyed');
|
||||
if (!active) return { student_id: s.student_id, status: 'none' };
|
||||
// Phase 4.1 §13 — before the destroy, so any still-open attempt is honestly abandoned
|
||||
// rather than left dangling once its environment no longer exists.
|
||||
await attemptsLib.abandonOpenAttemptsForEnvironment(getPool(), active.environment_id);
|
||||
await provisioner.destroy(active.environment_id);
|
||||
return { student_id: s.student_id, status: 'destroyed' };
|
||||
}));
|
||||
@@ -328,12 +350,14 @@ module.exports = function registerAdminRoutes(app, deps) {
|
||||
// resolving a bare environment id back to a cohort would need an extra Provisioner round
|
||||
// trip on every call — a documented simplification, see docs/PHASE3A...) ───────────────────
|
||||
admin.post('/api/admin/environments/:id/reset', requireAdminOrOwner, async (req, res) => {
|
||||
await attemptsLib.abandonOpenAttemptsForEnvironment(getPool(), req.params.id); // Phase 4.1 §13
|
||||
const { status, body } = await provisioner.reset(req.params.id);
|
||||
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'lab_reset', resourceType: 'environment', resourceId: req.params.id, result: status === 200 ? 'success' : 'failure', sourceIp: req.ip });
|
||||
res.status(status).json(body);
|
||||
});
|
||||
|
||||
admin.delete('/api/admin/environments/:id', requireAdminOrOwner, async (req, res) => {
|
||||
await attemptsLib.abandonOpenAttemptsForEnvironment(getPool(), req.params.id); // Phase 4.1 §13
|
||||
const { status, body } = await provisioner.destroy(req.params.id);
|
||||
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'lab_destroy', resourceType: 'environment', resourceId: req.params.id, result: status === 200 ? 'success' : 'failure', sourceIp: req.ip });
|
||||
res.status(status).json(body);
|
||||
@@ -423,8 +447,16 @@ module.exports = function registerAdminRoutes(app, deps) {
|
||||
});
|
||||
|
||||
// ── Audit log — admin/owner ──────────────────────────────────────────────────────────────
|
||||
// Phase 4.1 §16 — was a flat LIMIT 200 with no way to see anything older; now real
|
||||
// server-side pagination (see pagination.js). Default page size (50) intentionally smaller
|
||||
// than the old flat cap since this is now genuinely browsable instead of "the last 200 ever."
|
||||
admin.get('/api/admin/audit-events', requireAdminOrOwner, async (req, res) => {
|
||||
const { rows } = await getPool().query(`SELECT * FROM audit_events ORDER BY created_at DESC LIMIT 200`);
|
||||
res.json(rows);
|
||||
const pool = getPool();
|
||||
const pg = parsePagination(req);
|
||||
const [{ rows }, { rows: countRows }] = await Promise.all([
|
||||
pool.query(`SELECT * FROM audit_events ORDER BY created_at DESC LIMIT $1 OFFSET $2`, [pg.pageSize, pg.offset]),
|
||||
pool.query(`SELECT count(*)::int AS n FROM audit_events`),
|
||||
]);
|
||||
res.json(paginatedResponse(rows, countRows[0].n, pg));
|
||||
});
|
||||
};
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
// Stable server-side pagination — Phase 4.1 §16. Shared by every list endpoint that could
|
||||
// otherwise grow unbounded (audit log, attempt histories, assessment session history, scenario
|
||||
// analytics). Simple offset/limit, not cursor-based — this admin/instructor scale (hundreds to
|
||||
// low thousands of rows, not a high-frequency infinite-scroll feed) doesn't need more than
|
||||
// that, and offset pagination is what every other query in this codebase already reads like.
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 50;
|
||||
const MAX_PAGE_SIZE = 200;
|
||||
|
||||
// Reads ?page=&page_size= from a request, clamped to sane bounds — never trusts the client for
|
||||
// an unbounded page_size (that would defeat the entire point).
|
||||
function parsePagination(req) {
|
||||
const page = Math.max(1, parseInt(req.query.page, 10) || 1);
|
||||
const pageSize = Math.min(MAX_PAGE_SIZE, Math.max(1, parseInt(req.query.page_size, 10) || DEFAULT_PAGE_SIZE));
|
||||
return { page, pageSize, offset: (page - 1) * pageSize };
|
||||
}
|
||||
|
||||
function paginatedResponse(rows, total, { page, pageSize }) {
|
||||
return { items: rows, page, page_size: pageSize, total, total_pages: Math.max(1, Math.ceil(total / pageSize)) };
|
||||
}
|
||||
|
||||
module.exports = { parsePagination, paginatedResponse, DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE };
|
||||
@@ -33,6 +33,7 @@ const provisioner = {
|
||||
reset: (id) => call('POST', `/environments/${encodeURIComponent(id)}/reset`),
|
||||
destroy: (id) => call('DELETE', `/environments/${encodeURIComponent(id)}`),
|
||||
events: (id) => call('GET', `/environments/${encodeURIComponent(id)}/events`),
|
||||
capacity: () => call('GET', '/capacity'), // Phase 4.1 §26 — instructor dashboard capacity state
|
||||
};
|
||||
|
||||
module.exports = { provisioner };
|
||||
|
||||
@@ -78,5 +78,61 @@ module.exports = function createRbac(pool) {
|
||||
};
|
||||
}
|
||||
|
||||
return { requireRole, requireOwner, requireAdminOrOwner, requireAnyInstructor, requireCohortAccess, requireStudentCohortAccess };
|
||||
// Phase 4.1 §19 — cohort_instructors.role_in_cohort existed since Phase 2B but was never
|
||||
// read anywhere (a misleading field: it looked authoritative but did nothing). Minimal
|
||||
// useful model: 'teacher' (default, existing full instructor capability within the cohort)
|
||||
// vs 'viewer' (read-only — sees dashboards/progress/analytics via requireCohortAccess above,
|
||||
// but cannot Prepare/Destroy Labs or provision a student's environment). owner/admin still
|
||||
// bypass entirely, same as every other cohort-scoped check.
|
||||
function requireCohortWriteAccess(cohortIdParam = 'id') {
|
||||
return async (req, res, next) => {
|
||||
const instructor = req.instructor;
|
||||
if (['owner', 'admin'].includes(instructor.role)) return next();
|
||||
const cohortId = req.params[cohortIdParam];
|
||||
const { rows } = await pool.query(
|
||||
`SELECT role_in_cohort FROM cohort_instructors WHERE cohort_id = $1 AND instructor_id = $2`,
|
||||
[cohortId, instructor.instructor_id]
|
||||
);
|
||||
if (!rows.length || rows[0].role_in_cohort === 'viewer') {
|
||||
await recordAudit(pool, {
|
||||
actorType: 'instructor', actorId: instructor.instructor_id, action: 'authz_denied',
|
||||
resourceType: 'cohort', resourceId: cohortId, result: 'failure', sourceIp: req.ip,
|
||||
metadata: rows.length ? { reason: 'viewer_role' } : undefined,
|
||||
});
|
||||
return res.status(403).json({ error: 'forbidden' });
|
||||
}
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
// Same, keyed by student_id — resolves through cohort_enrollments the same way
|
||||
// requireStudentCohortAccess does, then applies the same viewer check.
|
||||
function requireStudentCohortWriteAccess(studentIdParam = 'id') {
|
||||
return async (req, res, next) => {
|
||||
const instructor = req.instructor;
|
||||
if (['owner', 'admin'].includes(instructor.role)) return next();
|
||||
const studentId = req.params[studentIdParam];
|
||||
const { rows } = await pool.query(
|
||||
`SELECT ci.role_in_cohort 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`,
|
||||
[studentId, instructor.instructor_id]
|
||||
);
|
||||
if (!rows.length || rows.every((r) => r.role_in_cohort === 'viewer')) {
|
||||
await recordAudit(pool, {
|
||||
actorType: 'instructor', actorId: instructor.instructor_id, action: 'authz_denied',
|
||||
resourceType: 'student', resourceId: studentId, result: 'failure', sourceIp: req.ip,
|
||||
metadata: rows.length ? { reason: 'viewer_role' } : undefined,
|
||||
});
|
||||
return res.status(403).json({ error: 'forbidden' });
|
||||
}
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
requireRole, requireOwner, requireAdminOrOwner, requireAnyInstructor,
|
||||
requireCohortAccess, requireStudentCohortAccess,
|
||||
requireCohortWriteAccess, requireStudentCohortWriteAccess,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -16,7 +16,7 @@ const { provisioner } = require('./provisioner-client');
|
||||
const curriculum = require('./curriculum');
|
||||
const { loadProgress, saveOneProgress, touchStudent } = require('./progress');
|
||||
const { recordAudit } = require('./audit');
|
||||
const { parseCookies, checkCsrf, makeCsrfToken } = require('./security');
|
||||
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');
|
||||
@@ -88,6 +88,26 @@ app.get('/healthz', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// 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
|
||||
// ═══════════════════════════════════════════════════════════════════════════════════════
|
||||
@@ -136,7 +156,12 @@ app.post('/api/login', async (req, res) => {
|
||||
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 });
|
||||
@@ -268,6 +293,12 @@ app.post('/api/environment/launch', wrap(studentSession), async (req, res) => {
|
||||
const retry = await provisioner.listForStudent(req.studentId);
|
||||
return res.json(await publicEnvView(retry.body?.[0]));
|
||||
}
|
||||
// Phase 4.1 §26 — relayed as its own clean status/message, not folded into the generic
|
||||
// provision_failed 502 below. The student sees "temporarily full, try again shortly," never
|
||||
// a raw 500/502 — this is expected operational backpressure, not an application error.
|
||||
if (status === 503 && body?.code === 'CAPACITY_EXCEEDED') {
|
||||
return res.status(503).json({ error: 'capacity_exceeded', message: 'Lab capacity is temporarily full. Try again shortly.' });
|
||||
}
|
||||
if (status !== 201) return res.status(502).json({ error: 'provision_failed', message: body?.error });
|
||||
res.json(await publicEnvView({ environment_id: body.environment_id, status: body.status, routing_token: body.routing_token }));
|
||||
});
|
||||
@@ -276,6 +307,11 @@ 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));
|
||||
@@ -322,6 +358,7 @@ 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
|
||||
app.listen(PORT, () => console.log(`Platform API listening on :${PORT}`));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user