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:
@@ -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