feat(hints): wire student-facing learning API and progressive hint reveal
New learning-routes.js: scenario start/attempt-state/hint (sequential reveal, 409 once exhausted, policy-aware under an active assessment), GET /api/curriculum/state (rule-driven, alongside the unchanged /api/curriculum/path), assessments list/start/active/detail/submit, GET /api/capstone, GET /api/achievements, GET /api/attempts/recent. /api/progress/record now routes through attempts.recordValidation instead of a flat insert, and, on a pass, checks curriculum/capstone achievement consequences and, if the scenario is part of an active assessment, updates that session (assessment context is derived entirely server-side from assessment_session_scenarios, never from anything the client asserts). Gateway changes: two new relay routes (/lab/:token/api/scenarios/:id/start and .../hint, mirroring the existing validate/answer pattern - pure Platform API operations, the Lab has no attempt/hint concept) and a response rewrite on GET /lab/:token/api/scenarios/:id that strips the Lab's own hints array (replacing it with a bare hint_count) so progressive reveal is actually server-gated instead of just UI-collapsed - the Lab image's bytes stay untouched; standalone/single-container dev mode never goes through the Gateway and keeps the full array unaffected.
This commit is contained in:
@@ -166,6 +166,66 @@ app.post('/lab/:token/api/scenarios/:scenarioId/answer', express.json(), async (
|
||||
});
|
||||
});
|
||||
|
||||
// ── Phase 3B learning relay — /start and /hint are pure Platform API operations, unlike
|
||||
// validate/answer above (those call the Lab first, then relay the outcome). The Lab has no
|
||||
// concept of attempts, hints-as-tracked-state, or assessments; these forward straight through
|
||||
// to Platform API with the browser's own session + CSRF token (same forwarding requirement
|
||||
// documented on relayScenarioOutcome above — both cookie AND header, not just the header). ────
|
||||
async function relayToPlatformApi(req, res, platformPath, extraBody) {
|
||||
const cookies = parseCookies(req);
|
||||
const token = cookies[STUDENT_COOKIE];
|
||||
const csrf = cookies[STUDENT_CSRF_COOKIE] || '';
|
||||
try {
|
||||
const platformRes = await fetch(`${PLATFORM_API_URL}${platformPath}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Cookie: `${STUDENT_COOKIE}=${token}; ${STUDENT_CSRF_COOKIE}=${csrf}`,
|
||||
'X-CSRF-Token': csrf,
|
||||
},
|
||||
body: JSON.stringify({ ...(req.body || {}), ...extraBody }),
|
||||
});
|
||||
const body = await platformRes.json().catch(() => ({}));
|
||||
res.status(platformRes.status).json(body);
|
||||
} catch (e) {
|
||||
res.status(502).json({ error: 'platform_unreachable' });
|
||||
}
|
||||
}
|
||||
|
||||
app.post('/lab/:token/api/scenarios/:scenarioId/start', express.json(), async (req, res) => {
|
||||
const env = await resolveByToken(req.params.token);
|
||||
const own = await checkOwnership(req, env);
|
||||
if (!own.ok) return sendLabError(res, own.reason === 'invalid_token' ? 404 : 403, own.reason, 'This lab is not available to you.');
|
||||
await relayToPlatformApi(req, res, `/api/scenarios/${req.params.scenarioId}/start`, { environment_id: env.environment_id });
|
||||
});
|
||||
|
||||
app.post('/lab/:token/api/scenarios/:scenarioId/hint', express.json(), async (req, res) => {
|
||||
const env = await resolveByToken(req.params.token);
|
||||
const own = await checkOwnership(req, env);
|
||||
if (!own.ok) return sendLabError(res, own.reason === 'invalid_token' ? 404 : 403, own.reason, 'This lab is not available to you.');
|
||||
await relayToPlatformApi(req, res, `/api/scenarios/${req.params.scenarioId}/hint`, { environment_id: env.environment_id });
|
||||
});
|
||||
|
||||
// Strips `hints` from the Lab's own scenario-detail response before it reaches the browser —
|
||||
// the Lab backend still returns the full array (it has no student/attempt concept and was
|
||||
// never modified), but hints must only be revealed progressively through the endpoint above.
|
||||
// The Lab image itself stays byte-for-byte unmodified; this is a response rewrite at the
|
||||
// Gateway, not a Lab change.
|
||||
app.get('/lab/:token/api/scenarios/:scenarioId', async (req, res) => {
|
||||
const env = await resolveByToken(req.params.token);
|
||||
const own = await checkOwnership(req, env);
|
||||
if (!own.ok) return sendLabError(res, own.reason === 'invalid_token' ? 404 : 403, own.reason, 'This lab is not available to you.');
|
||||
if (!isReachable(env)) return sendLabError(res, 503, 'lab_unavailable', 'Your lab is not currently running.');
|
||||
try {
|
||||
const labRes = await fetch(`http://${env.internal_ip}:${env.internal_port}/api/scenarios/${req.params.scenarioId}`);
|
||||
const body = await labRes.json().catch(() => ({}));
|
||||
const { hints, ...withoutHints } = body;
|
||||
res.status(labRes.status).json({ ...withoutHints, hint_count: Array.isArray(hints) ? hints.length : 0 });
|
||||
} catch (e) {
|
||||
res.status(502).json({ error: 'lab_unreachable' });
|
||||
}
|
||||
});
|
||||
|
||||
// ── /lab/:token/* — HTTP proxy into the student's own Lab, prefix-stripped ─────────────────
|
||||
app.use('/lab/:token', async (req, res, next) => {
|
||||
const { token } = req.params;
|
||||
|
||||
Reference in New Issue
Block a user