// Platform Shell API client. Identity is a server-controlled session cookie (rn_session, // HttpOnly — this code can't read it, and doesn't need to; the browser sends it automatically // on every same-origin request). Roster name-picking is classroom/pilot identification, not // production authentication (see docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md §5) — what's // hardened here is the session that results from it: persistent (survives an API restart), // unforgeable, and checked on every request. // // display_name is still cached in sessionStorage purely for UI convenience (showing "Hi, // Alice" without an extra round trip) — it carries no authority; every actual API call is // authorized by the cookie alone, not by anything read from here. // // rn_csrf_s is a deliberately non-HttpOnly cookie (double-submit CSRF pattern) — this code // reads it and echoes it back as a header on every state-changing request, proving the request // came from our own origin's JS (a cross-origin attacker can trigger the cookie but can't read // its value or attach a matching custom header without CORS, which we don't grant elsewhere). const DISPLAY_CACHE_KEY = 'rootnode_student_display' const CSRF_COOKIE = 'rn_csrf_s' export function getCachedDisplay() { try { return JSON.parse(sessionStorage.getItem(DISPLAY_CACHE_KEY) || 'null') } catch { return null } } function cacheDisplay(student) { sessionStorage.setItem(DISPLAY_CACHE_KEY, JSON.stringify(student)) } function clearCachedDisplay() { sessionStorage.removeItem(DISPLAY_CACHE_KEY) } function getCookie(name) { return document.cookie.split('; ').find((c) => c.startsWith(`${name}=`))?.split('=')[1] } async function json(res) { if (!res.ok) { const body = await res.json().catch(() => ({})) throw new Error(body.message || body.error || `Request failed (${res.status})`) } return res.json() } function mutate(path, body) { return fetch(path, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCookie(CSRF_COOKIE) || '' }, body: body ? JSON.stringify(body) : undefined, }).then(json) } export const api = { cohorts: () => fetch('/api/roster/cohorts').then(json), roster: (cohort) => fetch(`/api/roster?cohort=${encodeURIComponent(cohort)}`).then(json), login: async (student_id) => { // No CSRF token yet at login (nothing authenticated to forge into) — same reasoning as the // instructor login route. const student = await fetch('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ student_id }), }).then(json) cacheDisplay(student) return student }, logout: async () => { await mutate('/api/logout').catch(() => {}) clearCachedDisplay() }, whoami: () => fetch('/api/session/whoami').then(json), curriculumPath: () => fetch('/api/curriculum/path').then(json), curriculumState: () => fetch('/api/curriculum/state').then(json), environmentStatus: () => fetch('/api/environment/status').then(json), launchEnvironment: () => mutate('/api/environment/launch'), resetEnvironment: () => mutate('/api/environment/reset'), recentAttempts: () => fetch('/api/attempts/recent').then(json), assessments: () => fetch('/api/assessments').then(json), activeAssessment: () => fetch('/api/assessments/active').then(json), assessmentDetail: (sessionId) => fetch(`/api/assessments/${sessionId}`).then(json), startAssessment: (definitionId) => mutate(`/api/assessments/${definitionId}/start`), submitAssessment: (sessionId) => mutate(`/api/assessments/${sessionId}/submit`), capstone: () => fetch('/api/capstone').then(json), achievements: () => fetch('/api/achievements').then(json), }