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:
2026-08-24 20:15:39 -05:00
parent addf474b70
commit a8e543234c
13 changed files with 369 additions and 60 deletions
+42 -10
View File
@@ -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));
});
};