11 Commits
Author SHA1 Message Date
ced4568 45a147a61f docs(phase3b): document the learning and assessment engine
New docs/PHASE3B_LEARNING_AND_ASSESSMENT.md covers the attempt/learning-record model,
progressive hints (including the Gateway response-rewrite mechanism), curriculum completion
rules, the assessment/exam engine and its timer/draw integrity guarantees, the RootNode
Capstone design, achievement records, the deliberately minimal versioning model, instructor
analytics, the Phase 3B RBAC matrix, security/integrity assumptions, dev/test commands, known
limitations, and the Phase 4 boundary.

Updates ENGINEERING_SOP.md (roadmap items 2/7/8 now done, new section 12, corrects a stale
capstone-placeholder description in section 4), LAB_ARCHITECTURE.md's status line,
README.md (corrects the 'Lab image unmodified' claim now that its frontend bundle carries
Phase 3B UX, plus new platform properties), platform/README.md, and platform/api/README.md.
2026-08-18 14:36:13 -05:00
ced4568 8956a95446 test(phase3b): add learning and assessment coverage
24 cases: attempt telemetry (start/fail/pass counters, duration), progressive hint sequencing
and exhaustion, hint persistence through a real Lab reset, curriculum stage lock/unlock,
capstone lock-before-prerequisites/unlock-after/random-draw-includes-required-Real-Ops/
cannot-double-start/completion-records-achievement, exam assignment/draw/timer/hint-policy,
cross-student session-read rejection (404, not 403 - consistent with the existing don't-confirm-
existence posture), two-student isolation, timer survives a Lab reset, an assessment session
survives a REAL docker compose restart api, and instructor analytics/needs-attention/RBAC.

Found and fixed two real product bugs in the process: result needed to become nullable (an
in-progress attempt has none yet) and hint_count needed a real DEFAULT 0 (Phase 2B left it
NULL/undefaulted since it wasn't measured yet - it is now, and NULL + 1 stays NULL in SQL).
2026-08-18 14:36:13 -05:00
ced4568 d4db8f99f5 feat(student): add learning tabs and Lab hint/assessment UX
StudentDashboard is now tabbed: My Learning (the original Phase 2A/2B flow, extracted
unchanged into MyLearningTab and extended with an overall percent-complete bar), Practice,
Assessments (available/active-with-live-countdown/history), Capstone (locked checklist ->
unlocked -> start/resume -> completed), Achievements (a flat dated list, not a leaderboard).

Inside the Lab UI: ProgressiveHints replaces the static hint array render when the Gateway has
stripped hints (detected via scenario.hints being undefined but hint_count present - standalone
dev mode never sees this, so it keeps the original behavior); a new top-level AssessmentBanner
shows a live countdown and completion count whenever a Platform assessment is running,
rendered alongside (not replacing) the pre-existing Lab-local exam-mode UI, which is untouched.
2026-08-18 14:35:59 -05:00
ced4568 7e5f357b6f feat(admin): add instructor learning analytics and assessment management
New /api/admin routes: cohort learning summary (completion by stage, active learners),
per-student learning detail (attempts, hint usage, assessment history, capstone state),
scenario analytics (completion rate, avg attempts, failure/hint frequency, avg time), and a
needs-attention view (repeated failures, hints used but still stuck, inactivity, an expired
assessment - deliberately instructional wording, not punitive, with an explicit test asserting
that). Assessment definition CRUD (admin/owner only), cohort assignment and session view/cancel
(any instructor role, cohort-scoped for a plain instructor via the existing Phase 3A RBAC
helpers - no new authorization primitives needed).

New Learning and Assessments tabs in the admin portal, visible to every instructor role;
definition-authoring controls are shown but the server 403s a create/edit from a non-admin/
owner role, same pattern CohortsView already uses for admin-only controls.
2026-08-18 14:35:59 -05:00
ced4568 4e3dacc043 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.
2026-08-18 14:35:44 -05:00
ced4568 0beb0e8f5e feat(learning): add persistent achievement records
New achievements table: stage_completed/curriculum_completed/assessment_completed/
capstone_completed, idempotent (WHERE NOT EXISTS guard, not a unique-index dance — simple and
correct at this write volume). Not a certificate — a durable internal record Phase 4/5 can
render one from later. recordAssessmentAchievement only fires on an actual pass, defensively,
even though today's only completion_rule already guarantees that.
2026-08-18 14:35:11 -05:00
ced4568 b908ade8e0 feat(assessment): add exam and capstone definition/session engine
New tables: assessment_definitions (reusable template — pool_spec, duration, hint_policy,
completion/unlock rules, a plain incrementing version bumped only on semantic edits),
assessment_cohort_assignments, assessment_sessions (one per student attempt, full snapshot of
what was drawn and under what rules — never affected by a later definition edit),
assessment_session_scenarios (the drawn set, ordered, with a real FK to scenario_attempts).
Deliberately does not touch/reuse exam_sessions (migration 006) — that was already scoped in
Phase 2B as historical-import-only, never the live mechanism; these tables are the real thing.

drawScenarios: every 'required' scenario id included, plus N random picks per pool group,
shuffled together (tiny seeded PRNG, mulberry32 — no new dependency; the seed is recorded for
audit, never shown to the student before the draw). One assessment at a time per student.
Timer is server-authoritative (expires_at fixed at draw time) and lazily expired — checked
whenever a session is touched, no background cron. Ships two seeded defaults: rootnode-capstone
(Real Ops scenarios required, weighted toward operational ability) and rootnode-quick-assessment
(a general example exam matching the spec's category-draw example).
2026-08-18 14:35:11 -05:00
ced4568 01a862e007 feat(learning): add scenario attempt telemetry and curriculum completion rules
Evolves scenario_attempts (Phase 2B: one row per validation click) into a real attempt
lifecycle — one row per continuous working session, with live counters (validation
attempts/passes/fails, hint reveals, duration) updated in place, not re-inserted. Backward
compatible: every new column is nullable/defaulted, existing rows backfilled under the new
semantics rather than dropped. hint_count (Phase 2B, always NULL — 'not measurable') finally
gets a real DEFAULT 0 and increments, since this is the phase that builds real hint tracking.

curriculum-rules.js computes rule-driven stage lock/complete/percent/next-scenario state from
scenarios/rootnode-path.json's new per-stage completion_rule (all_required/threshold/
prerequisites) against a student's progress. The capstone stage is no longer a hardcoded
locked placeholder — it carries an unlock_rule and a pointer at the assessment definition that
actually defines its scenario draw (see the assessment-engine commit).
2026-08-18 14:35:11 -05:00
ced4568 e793d9476b docs(deps): document the dependency-lock convention
Was ambiguous — backend/frontend committed lockfiles, api/gateway/provisioner didn't, with
no stated reason either way. Now explicit: every first-party Node service commits its
package-lock.json, validated with npm ci before each commit that touches it.
2026-08-18 13:50:38 -05:00
ced4568 957a99645a chore(deps): track gateway and provisioner package locks
backend/ and frontend/ already had committed lockfiles; platform/api/ just gained one.
gateway/ and provisioner/ never had one generated at all — not a deliberate policy, an
inconsistency. Generated via npm install --package-lock-only and validated with a clean
npm ci for both (72 and 153 packages respectively, no resolution surprises).
2026-08-18 13:50:13 -05:00
ced4568 60a1e79930 chore(deps): track platform API package lock
Was generated locally during Phase 3A work but never committed. Verified it corresponds
to the current package.json via a clean npm ci (118 packages, no resolution changes).
2026-08-18 13:49:35 -05:00
38 changed files with 6669 additions and 215 deletions
+31 -17
View File
@@ -127,27 +127,35 @@ Everything runs inside one image managed by `scripts/entrypoint.sh`.
## Multi-Student Deployment (RootNode Academy addition)
The upstream project assumes one learner, one container — this image is still exactly that,
unmodified except for a node-pty build fix (see `docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md` §1).
RootNode Academy multiplies it into a real platform via separate provisioning and gateway
layers rather than changing the image itself:
The upstream project assumes one learner, one container — this image is still exactly that.
Its backend/entrypoint/Dockerfile are unmodified except for a node-pty build fix (see
`docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md` §1); its frontend bundle gained progressive-hint
controls and an assessment-mode indicator in Phase 3B (`docs/PHASE3B_LEARNING_AND_ASSESSMENT.md`
§10–11) — additive UI, not a rewrite of the Lab experience. RootNode Academy multiplies the
image into a real platform via separate provisioning and gateway layers rather than changing
its core:
- `platform/provisioner/` — Postgres-backed environment registry, per-student Docker
networking, firewall-enforced egress, idle reaper (`docs/PHASE1_PLATFORM_FOUNDATION.md`).
- `platform/api/` — lightweight Platform API: students, cohorts, curriculum, progress,
scenario attempts, persistent student/instructor sessions, per-instructor accounts (RBAC:
owner/admin/instructor), audit log, instructor/admin control plane. No k3s, no `--privileged`
(`docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md`, `docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md`).
- `platform/api/` — lightweight Platform API: students, cohorts, curriculum, progress, detailed
scenario attempt telemetry, progressive hints, assessment/capstone engine, achievements,
persistent student/instructor sessions, per-instructor accounts (RBAC: owner/admin/
instructor), audit log, instructor/admin control plane. No k3s, no `--privileged`
(`docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md`, `docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md`,
`docs/PHASE3B_LEARNING_AND_ASSESSMENT.md`).
- `platform/gateway/` — the only student-facing path to a Lab: resolves identity from a
server-controlled, persistent session cookie (not a client-suppliable header/param), proxies
HTTP/WebSocket into that one student's container, never a raw container IP/port
(`docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md`, session model in
`docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md` §3).
- The student flow (roster login → curriculum dashboard → Launch Lab → Lab session →
Reset/Recreate) lives in `frontend/src/platform/`; the instructor/admin portal (per-instructor
login, cohorts, students, instructor accounts, audit log, classroom "Prepare Labs,"
environment controls) lives in `frontend/src/admin/` at the `/admin` route — both small
additions alongside the existing, unmodified Lab UI, not a rewrite of it.
HTTP/WebSocket into that one student's container, never a raw container IP/port, and
server-gates progressive hint reveal by stripping the Lab's own hint payload before it reaches
the browser (`docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md`, session model in
`docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md` §3, hint gating in
`docs/PHASE3B_LEARNING_AND_ASSESSMENT.md` §3).
- The student flow (roster login → curriculum dashboard → Launch Lab → practice/assessments/
capstone → achievements) lives in `frontend/src/platform/`; the instructor/admin portal
(per-instructor login, cohorts, students, instructor accounts, audit log, classroom
"Prepare Labs," environment controls, learning analytics, assessment/capstone management)
lives in `frontend/src/admin/` at the `/admin` route — both additions alongside the existing
Lab UI, not a rewrite of it.
**Never publish the raw privileged container** — front it with isolation (that's what the
Gateway is). The one-container `docker run --privileged -p 7554:80 ...` command above stays
@@ -171,6 +179,12 @@ Key properties of the platform (not this image):
recorded in an audit log.
- Sessions (student and instructor) are Postgres-backed and survive a Platform API restart —
verified by actually restarting it mid-session and confirming access still works.
- Every validation attempt, hint reveal, curriculum stage completion, exam, and Capstone
attempt is recorded in Postgres, not the disposable Lab — a Lab reset or recreate never loses
attempt telemetry, hint progress, or an in-progress assessment's timer. Verified live:
reset a Lab mid-assessment, timer and drawn scenario set were unchanged afterward.
- Exams and the RootNode Capstone use a server-authoritative timer and a server-drawn,
server-stored scenario set — nothing the browser sends can change either.
- CPU/memory/PID/storage limits are enforced per container, config-driven.
- Egress is restricted to an allowlisted proxy at the firewall layer, not left open.
- Idle/abandoned environments are automatically cleaned up, and a student can launch a fresh
@@ -178,7 +192,7 @@ Key properties of the platform (not this image):
Production topology and what's built vs. not yet: `docs/LAB_ARCHITECTURE.md`,
`docs/PHASE1_PLATFORM_FOUNDATION.md`, `docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md`,
`docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md`.
`docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md`, `docs/PHASE3B_LEARNING_AND_ASSESSMENT.md`.
---
+59 -12
View File
@@ -71,14 +71,18 @@ Two separate structures, don't confuse them:
- **RootNode path** (`scenarios/rootnode-path.json`) — the guided onboarding sequence,
separate from bundles on purpose (it's *staged*, bundles are *flat*, and the bundle loader
would choke on a staged file if it were dropped in `scenarios/bundles/`). Loaded by
`loadCurriculumPath()`/`GET /api/curriculum/path`. Stages are Foundation → Workloads →
Networking → Configuration → Troubleshooting → Real Ops → Capstone, currently built by
reusing existing `1-k8s-basics` and `rootnode-real-ops` scenarios grouped by their existing
`category` field — no new scenario content was written for this. **Capstone stage is a
locked placeholder** (`"locked": true`, empty `scenario_ids`) — the capstone engine itself
is a separate, not-yet-built roadmap item (see §7, item 6). Do not put real scenarios in it
until that engine exists, or you'll reproduce the "duplicate beginner scenarios as capstone"
anti-pattern this path was built to avoid.
`loadCurriculumPath()`/`GET /api/curriculum/path` (raw content) and, as of Phase 3B,
`computeCurriculumState()`/`GET /api/curriculum/state` (rule-driven lock/complete/next-scenario
state — see `docs/PHASE3B_LEARNING_AND_ASSESSMENT.md` §4). Stages are Foundation → Workloads →
Networking → Configuration → Troubleshooting → Real Ops → Capstone, built by reusing existing
`1-k8s-basics` and `rootnode-real-ops` scenarios grouped by their existing `category` field —
no new scenario content was written for this. Each stage carries a `completion_rule`
(`all_required`/`threshold`/`prerequisites`). **Capstone is no longer a hardcoded placeholder**
— it's `"type": "capstone"` with an `"unlock_rule"` and a `"capstone_assessment_id"` pointing
at the `rootnode-capstone` assessment definition (Postgres, `assessment_definitions` table),
which is where its actual scenario draw lives — see the Phase 3B doc §7. Don't put real
scenarios directly into the JSON stage's `scenario_ids` for capstone; that field stays empty
by design, the draw is defined in the assessment definition instead.
To add/reorder a stage: edit `rootnode-path.json` directly, referencing existing
`scenarios/data/*.json` ids. No backend code change needed — it's read fresh via
@@ -138,7 +142,9 @@ Order agreed 2026-08-17/18. Status as of this doc:
1. RootNode identity/branding pass — **partial.** Shell banner fixed
(`⎈ RootNode Academy Lab — node: k8s-lab`). Upstream KubeKosh attribution left untouched in
footer/README/LICENSE. Broader "make Real Ops first-class" UI work not started.
2. RootNode curriculum mode — **backend done** (§4), frontend not started.
2. RootNode curriculum mode — **done**. Rule-driven stage completion/unlock as of Phase 3B
(§12), student-facing tabs (My Learning/Practice/Assessments/Capstone/Achievements) at
`frontend/src/platform/`.
3. Student accounts / persistent progress — **done**, Postgres-authoritative as of Phase 2B
(§9), sessions persistent as of Phase 3A (§10). Roster name-picking is still classroom/pilot
identification, not strong student auth — documented, not silently glossed over
@@ -156,15 +162,18 @@ Order agreed 2026-08-17/18. Status as of this doc:
documented adapter, not a working integration (docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md
§16).
6. Scenarios → teaching modules (incident context/investigation/prevention) — not started.
7. Capstone/exam engine — not started (placeholder stage only, see §4).
8. Progressive hint levels — not started.
7. Capstone/exam engine — **done** as of Phase 3B (§12). RootNode Capstone (Real Ops-weighted,
unlockable by curriculum-completion rule, not a permanent placeholder) plus a general exam
engine (definitions, cohort assignment, random scenario draw, server-authoritative timer).
8. Progressive hint levels — **done** as of Phase 3B (§12). Server-enforced sequential reveal,
tracked per attempt, policy-aware under assessment mode (free/disabled/flagged).
9. Production observability — not started.
10. CI scenario testing — not started.
11. Container/build optimization — explicitly deprioritized by design (platform works, not urgent).
Next slice: production OIDC/SSO, the production two-VM Proxmox deployment, and teaching-module
scenario content are the biggest remaining items. See
`docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md` §18 for the full BUILT NOW vs. FUTURE split.
`docs/PHASE3B_LEARNING_AND_ASSESSMENT.md` §21 for the full Phase 4 boundary.
## 8. Platform Provisioner (Phase 1)
@@ -233,3 +242,41 @@ Full design: `docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md`. Operational refer
scoping, CSRF, rate limiting, password change, and audit logging.
- **Known limitations, future OIDC path, production HTTPS expectations:** see
`docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md` §15–17.
## 11. Dependency lock convention
Every first-party Node service commits its `package-lock.json`: `backend/`, `frontend/`,
`platform/api/`, `platform/gateway/`, `platform/provisioner/`. This wasn't consistently true
before Phase 3B — `platform/api/`'s lockfile existed locally but was never committed, and
`platform/gateway/`/`platform/provisioner/` never had one generated at all. Neither was a
deliberate policy; both were oversights, fixed in Phase 3B (`chore(deps)` commits).
Going forward: after any `npm install` that changes a service's dependencies, the updated
`package-lock.json` is part of the same commit as the `package.json` change — never left
untracked, never regenerated-and-ignored. Validate a lockfile actually matches its
`package.json` with `npm ci` (fails loudly on drift) before committing either.
## 12. Learning & Assessment Engine (Phase 3B)
Full design: `docs/PHASE3B_LEARNING_AND_ASSESSMENT.md`.
- **What it is:** the teaching/assessment layer on top of everything Phases 1–3A built —
detailed per-attempt telemetry (`scenario_attempts`, evolved from Phase 2B's one-row-per-click
shape into a real attempt lifecycle), server-enforced progressive hints, rule-driven
curriculum stage/capstone unlock logic, a full exam + capstone assessment engine
(`assessment_definitions`/`assessment_sessions`/`assessment_session_scenarios`), and
persistent `achievements` records.
- **Where the boundary moved:** the Gateway now rewrites one Lab response
(`GET /lab/:token/api/scenarios/:scenarioId`, stripping `hints`) so progressive hints can be
genuinely server-gated rather than just UI-collapsed — the Lab image's own code/bytes are
still untouched; this is a response rewrite at the Gateway boundary, same pattern as every
other Lab-mediation decision made in earlier phases. The Lab's own frontend bundle *did*
change this phase (`ScenarioPanel.jsx`, new `ProgressiveHints`/`AssessmentBanner` components)
— Phase 3B explicitly authorized enhancing Lab UX, unlike prior phases.
- **Test procedure:** `platform/test/e2e-phase3b.js` — covers attempt telemetry, hint
sequencing/persistence-through-Lab-reset, curriculum/capstone lock-unlock, the exam engine
(random draw, timer, expiry, restart persistence), two-student isolation, and instructor
analytics/RBAC.
- **Known limitations:** see the Phase 3B doc §20 — no automatic abandoned-attempt detection, no
per-cohort needs-attention threshold configuration, raw-JSON pool_spec authoring in the admin
UI (correct, not yet a guided form).
+10 -8
View File
@@ -2,14 +2,16 @@
Status: **Phase 1 (platform foundation) + Phase 2A (Gateway + student flow) + Phase 2B
(Platform API split, Postgres academic data, server sessions, admin portal) + Phase 3A
(persistent sessions, per-instructor accounts, RBAC, audit log) IMPLEMENTED, 2026-08-18** — see
`docs/PHASE1_PLATFORM_FOUNDATION.md`, `docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md`,
`docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md`, and `docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md`
for concrete schema/API/test results, and `platform/README.md` for how to run the stack. This
document remains the architecture record for the full ephemeral-lab direction; production
OIDC/SSO (an adapter interface exists, not a working integration — see the Phase 3A doc §16)
and production Proxmox deployment are still future work (Phase 3B+ — see the Phase 3A doc §18).
Supersedes the namespace-isolation
(persistent sessions, per-instructor accounts, RBAC, audit log) + Phase 3B (learning/assessment
engine: attempt telemetry, progressive hints, curriculum rules, exam + capstone engine,
achievements) IMPLEMENTED, 2026-08-18** — see `docs/PHASE1_PLATFORM_FOUNDATION.md`,
`docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md`, `docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md`,
`docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md`, and
`docs/PHASE3B_LEARNING_AND_ASSESSMENT.md` for concrete schema/API/test results, and
`platform/README.md` for how to run the stack. This document remains the architecture record
for the full ephemeral-lab direction; production OIDC/SSO (an adapter interface exists, not a
working integration) and production Proxmox deployment are still future work (Phase 4 — see the
Phase 3B doc §21). Supersedes the namespace-isolation
(`shared_namespace`/`ephemeral_cluster`) approach that was in flight before this redirect — see
`scenarios/SCHEMA.md`'s `isolation_type` note for the pointer back here.
+370
View File
@@ -0,0 +1,370 @@
# Phase 3B — Learning & Assessment Engine
Turns the platform infrastructure built in Phases 1–3A (identity, sessions, RBAC, disposable
Labs, Gateway routing, admin portal) into a real teaching and assessment system: detailed
attempt telemetry, progressive hints, rule-driven curriculum progression, a proper exam engine,
a RootNode Capstone, and persistent achievement records. Builds on
`docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md` — read that first if you haven't.
## 1. What changed, in one paragraph
Phase 2B's `scenario_attempts` table was one row per validation click, written atomically at
completion, with `hint_count` deliberately left unmeasured. Phase 3B evolves it into a real
attempt lifecycle — one row per continuous working session on a scenario, with live counters
(validation attempts/passes/fails, hint reveals, duration) updated in place as the student
works. On top of that: a rule-driven curriculum-progression engine that actually locks/unlocks
stages instead of a permanent capstone placeholder; a progressive hint system that's genuinely
server-enforced (not just collapsed-by-default in the UI); a full exam/capstone assessment
engine with server-authoritative timers and random scenario draws; and persistent achievement
records for stage/curriculum/assessment/capstone completion.
## 2. Learning record model
`scenario_attempts` (migration `012`) gained: `cohort_id`, `curriculum_stage` (both snapshotted
at attempt-start, so a later cohort/curriculum change never rewrites history), `status`
(`in_progress`/`completed`/`abandoned`), `validation_attempt_count`, `validation_pass_count`,
`validation_fail_count`, `highest_hint_level`, `duration_seconds`, `assessment_session_id`.
`result` (Phase 2B) is now nullable — meaningful only once `status = 'completed'`.
`hint_count` (Phase 2B, always `NULL`) finally gets a `DEFAULT 0` and real increments.
This is an **evolution, not a new table** — Phase 2B's historical rows are backfilled (one
validation performed, matching their original one-row-per-click semantics), not dropped or
reshaped. `platform/api/src/attempts.js` owns the lifecycle:
- `getOrCreateOpenAttempt` — finds the open (`in_progress`) attempt for
`(student, scenario, environment)`, or starts one. Environment-scoped so a destroyed/recreated
Lab never silently resumes an attempt tied to compute that no longer exists.
- `recordValidation` — increments the attempt/pass/fail counters; closes the attempt
(`status='completed'`, `duration_seconds` computed) only on a pass.
- `recordHintReveal` — sequential only: level *N* can't be revealed before *N-1* has been.
Returns a 409 once every hint is exhausted.
No telemetry is invented that the Lab doesn't actually emit — `duration_seconds` is measured
server-side from `started_at` to completion (not client-reported), and hint reveals only exist
because Phase 3B builds a real reveal endpoint (§3), not because the old static hint array was
reinterpreted as "measured."
## 3. Validation telemetry & progressive hints
`POST /api/scenarios/:id/start` — fire-and-forget from the Lab UI when a scenario becomes
active (both task and MCQ types); creates/reopens the attempt with a real `started_at`.
`POST /api/progress/record` (Gateway-relayed from the Lab's existing validate/answer calls,
unchanged mechanism from Phase 2A/2B) now routes through `attempts.recordValidation` instead of
a flat insert, and — on a pass — checks curriculum/capstone/assessment consequences (§4, §6).
`POST /api/scenarios/:id/hint` reveals hints **one at a time, server-enforced**. This required a
real architectural change: Phase 2B's Lab backend (unmodified, per the "Lab image stays
unmodified" convention through Phase 3A) returns the scenario's full `hints` array in
`GET /api/scenarios/:id` — always did, since the Lab has no student/attempt concept at all. A
client could already see hint 3 in the raw response even with the UI collapsed. Making the gate
real meant the Gateway now **strips `hints` from that response** (replacing it with a bare
`hint_count`) for exactly the multi-student, Platform-mediated deployment
(`platform/gateway/src/server.js`, `GET /lab/:token/api/scenarios/:scenarioId`) — the Lab
image's own bytes are still untouched; this is a response rewrite at the boundary, the same
pattern already used for the `hints`-stripping shape as everything else the Gateway mediates.
Standalone/single-container dev mode never goes through the Gateway, so it keeps the original
fully-preloaded array unchanged — the frontend detects which mode it's in
(`scenario.hints === undefined && typeof scenario.hint_count === 'number'`) and switches to
`ProgressiveHints` (`frontend/src/components/ProgressiveHints.jsx`) only in the gated case.
Instructor-visible attempt summary matches the shape in the Phase 3B spec directly:
```
Student: Alice Scenario: Probe Port Mismatch Attempts: 2
Validation: Failed 2, Passed 1 Hints used: 1/3 Time: 14m 22s Final: Completed
```
— all straight off `scenario_attempts` columns, no derived/estimated numbers, and explicitly
**not** turned into a score. See §12 for where instructors see this.
## 4. Curriculum completion rules
`scenarios/rootnode-path.json` gained a `version` field and, per stage, a `completion_rule`:
```json
{ "type": "all_required" } // every scenario_id in the stage must be completed (default)
{ "type": "threshold", "percent": 80 } // at least N% completed
{ "type": "prerequisites", "scenario_ids": [...] } // only these specific ids must be completed
```
The capstone stage is no longer a hardcoded `"locked": true` placeholder — it carries
`"type": "capstone"`, an `"unlock_rule"` (`{"type":"stages_complete","stage_ids":[...]}`), and a
`"capstone_assessment_id"` pointing at the assessment definition that drives it (§6/§7).
`platform/api/src/curriculum-rules.js`'s `computeCurriculumState(path, progress, capstoneStatus)`
is the rule engine — pure, synchronous, no DB access — answering: per-stage locked/complete/
percent/next-scenario, the student's current stage, overall percent complete, and the capstone's
unlock status (server route supplies `capstoneStatus` after checking for a
`capstone_completed` achievement, since that's Postgres state this module doesn't touch).
Exposed at `GET /api/curriculum/state` (new; `GET /api/curriculum/path` from Phase 2A/2B is
unchanged and still used by the pre-existing dashboard summary).
Stage/curriculum completion also triggers achievement recording (§8) automatically, from inside
`POST /api/progress/record` — no separate "check for completion" step the student or frontend
has to remember to call.
## 5 & 6. Assessment model and exam engine
`PRACTICE` is just... practice: the existing curriculum flow, hints freely available (subject to
the scenario actually having any), unrestricted retries, no timer, no session concept.
`ASSESSMENT` (exam or capstone — same underlying engine, §7 covers what makes a capstone
different) is a **definition** (the reusable template: pool, duration, hint policy, completion
rule) producing **sessions** (one per student attempt, immutable snapshot of what was drawn and
under what rules). New tables (migration `013`):
```
assessment_definitions slug PK, type ('exam'|'capstone'), title, duration_minutes,
hint_policy ('free'|'disabled'|'flagged'), pool_spec JSONB,
completion_rule JSONB, unlock_rule JSONB (capstone only),
version INTEGER, status ('active'|'inactive')
assessment_cohort_assignments which cohorts a definition is available to
assessment_sessions one per student attempt — snapshots type/title/duration_minutes/
hint_policy from the definition at draw time, plus
definition_version, started_at, server-computed expires_at,
submitted_at, random_seed, result JSONB
assessment_session_scenarios the drawn set, ordered, with per-scenario status + a real FK
back to the scenario_attempts row once worked
```
`exam_sessions` (Phase 1/2B, historical-import-only from the old Lab-local SQLite exam
bookkeeping) is **left untouched** — it was already scoped as dead historical data, never the
live mechanism, so there was nothing to migrate into the tables above without inventing data
that was never real. This is a deliberate non-duplication: the assessment tables above *are*
the real thing Phase 2B's note said didn't exist yet.
`pool_spec` shape:
```json
{ "required": ["scenario-id", ...], "random": [{ "count": 1, "from": ["a", "b", "c"] }, ...] }
```
`platform/api/src/assessments.js`'s `drawScenarios` includes every `required` id, plus `count`
random picks from each `random` group, shuffled together into presentation order. The draw uses
a tiny deterministic PRNG (mulberry32, seeded from `crypto.randomBytes` — no new dependency)
purely so the seed can be recorded for audit; a student never sees the seed or the undrawn pool
before starting.
**One assessment at a time per student** — `startSession` refuses if the student already has a
`not_started`/`in_progress` session for *any* definition. This keeps "which scenario is this
validate call happening inside" unambiguous for hint-policy enforcement (§19) without needing
the client to declare a mode.
**Timer**: `expires_at` is computed once, server-side, at `started_at + duration_minutes`, and
never recomputed from anything the client sends. Expiry is checked **lazily** — whenever a
session is read or written, if `now() > expires_at` and it's still `in_progress`, it flips to
`expired` right then. No background cron (explicitly out of scope this phase — see the
non-goals list). Verified live: a 1-minute test definition correctly transitioned to `expired`
on the first request after the minute passed, and unblocked starting a new assessment.
## 7. RootNode Capstone
A capstone is an `assessment_definitions` row with `type='capstone'` — same engine as an exam,
different framing and unlock gate. The shipped default (seeded in migration `013`, idempotent
`INSERT ... ON CONFLICT DO NOTHING`) is `rootnode-capstone`:
- **Required** (always drawn): all four RootNode Real Ops scenarios
(`rootnode-runaway-monitor`, `broken-deployment`, `rootnode-probe-port-mismatch`,
`rootnode-enforce-limits`) — "feature prominently," per the spec, satisfied by making them
non-optional rather than just likely.
- **Random**: one Workloads task, one Configuration task, one Troubleshooting task — task-type
scenarios preferred over MCQ, since the point is operational ability, not recall.
- 90 minutes, `hint_policy: 'flagged'` (available, but every use is recorded — appropriate for
a capstone that should mostly reflect independent work without being punitive about needing a
nudge).
- `unlock_rule`: every non-capstone curriculum stage complete (Foundation through Real Ops).
`GET /api/capstone` is a student-friendly view combining `computeCurriculumState`'s capstone
block with the definition and the student's own session history —
`locked` → `unlocked` → `completed`, with a prerequisite checklist while locked. Verified live,
end to end: locked with all six blocking stage ids, unlocked to zero blocking ids the moment the
last stage completed, a real draw (7 scenarios, all four Real Ops present), completion of all
seven finalizing the session and recording a `capstone_completed` achievement.
## 8. Achievement / completion records
`achievements` (migration `014`): `student_id`, `achievement_type`
(`stage_completed`/`curriculum_completed`/`assessment_completed`/`capstone_completed`),
`curriculum_path_id`/`curriculum_version` (for stage/curriculum types),
`assessment_session_id` (for assessment/capstone types), `achieved_at`, `metadata`.
`recordAchievement` is idempotent via a `WHERE NOT EXISTS` guard (not a unique-index/
`ON CONFLICT` dance — simple and correct at this write volume) — calling it again for something
already recorded is a safe no-op. Triggered automatically: `checkStageAndCurriculumAchievements`
runs inside `POST /api/progress/record` after every pass; `recordAssessmentAchievement` runs
after `assessments.recordScenarioOutcome`/`submitSession` finalizes a session, and only on an
actual pass (defensive — today's only `completion_rule` is `all_scenarios_passed`, which already
guarantees a `completed` session passed, but a future threshold-based rule shouldn't silently
grant achievements for a partial result).
**Not a certificate.** No public/shareable artifact is generated — this is explicitly a Phase
4/5 concern (see the non-goals list); a certificate can be rendered from these records later
without needing new data.
## 9. Assessment/exam Lab behavior
An assessment doesn't provision a separate Lab — the student works through their drawn
scenarios inside the **same** disposable environment their curriculum work already uses. This
is what makes "Lab reset doesn't erase assessment state" nearly free: assessment state
(`assessment_sessions`, `assessment_session_scenarios`, `scenario_attempts`) lives entirely in
Platform Postgres; the Lab environment is purely compute. Resetting/recreating it changes the
container, never the rows. Verified live: reset an environment mid-assessment,
`expires_at`/`status` on the session were unchanged afterward.
**Assessment-mode detection is entirely server-derived** — `findActiveAssessmentContextForScenario`
looks up whether the scenario a `validate`/`hint` call is happening against is currently in the
student's own active session's drawn set. Nothing the client sends declares "I'm in assessment
mode"; the server's own `assessment_session_scenarios` table is the only source of truth (see
§19).
If Lab infrastructure fails mid-assessment, the assessment session itself is unaffected — an
instructor can reset (or, if truly needed, destroy/reprovision) the student's Lab via the
existing admin environment controls (Phase 2B) without touching assessment state at all; there
is no code path that resets an assessment as a side effect of a Lab operation.
## 10 & 11. Student UI and Lab UX
`frontend/src/platform/StudentDashboard.jsx` is now tabbed: **My Learning** (the original
Phase 2A/2B "Up Next" + Launch/Reset Lab flow, extracted unchanged into `MyLearningTab.jsx` and
extended with an overall percent-complete bar from the new rule-driven curriculum state),
**Practice** (scenario/stage status + recent attempt history), **Assessments** (available/
active-with-live-countdown/history), **Capstone** (locked checklist → unlocked → start/resume →
completed, with attempt history), **Achievements** (a flat, dated list — not a leaderboard).
Inside the Lab UI itself (`frontend/src/components/ScenarioPanel.jsx`, `App.jsx`):
`ProgressiveHints` replaces the static hint array render when the Gateway has stripped hints
(§3); an `AssessmentBanner` (new, top-level, polls `/api/assessments/active`) shows a live
countdown and per-scenario completion count whenever an assessment is running — distinct from,
and rendered alongside, the pre-existing Lab-local exam-mode UI (`ExamTimer`/`ExamSession` etc.,
upstream KubeKosh feature, untouched). Both render nothing in standalone/single-container dev
mode (no Platform API to poll there), so neither regresses that deployment path.
## 12. Instructor learning analytics
`/admin` → **Learning**: cohort summary (student count, 7-day active-learner count, average
completion, per-stage completion counts), a **Needs Attention** list, and a per-student
drill-down (curriculum state, full attempt history with validation/hint counts, assessment
history). `/admin` → **Assessments**: create/edit definitions (admin/owner), assign to a cohort,
view/cancel sessions — visible to every instructor role, cohort-scoped server-side for the plain
`instructor` role the same way Phase 3A's other cohort-scoped routes are.
`GET /api/admin/scenario-analytics` (optional `?cohort_id=`): completions, distinct students,
average validation attempts, total failures, total hints used, average completion time — one
`GROUP BY scenario_id` query over `scenario_attempts`, nothing pre-aggregated or cached.
## 13. "Needs Attention"
`GET /api/admin/cohorts/:id/needs-attention` computes four signals per student, on read (no
stored table, no background job): **Repeated Attempts** (an open attempt with ≥3 failed
validations), **May Need Help** (an open attempt where a hint has been used but the scenario
isn't done), **Needs Attention — inactive** (no activity in 7 days), and **Needs Attention —
incomplete assessment** (their most recent assessment session expired without completing). All
four thresholds are named constants in `admin-learning-routes.js`
(`NEEDS_ATTENTION_FAIL_THRESHOLD`, `NEEDS_ATTENTION_STALE_DAYS`), not hardcoded inline. Wording
is deliberately instructional (`Needs Attention`, `May Need Help`, `Repeated Attempts`) — an
explicit test asserts none of the returned labels match punitive language.
## 14 & 15. Assessment/capstone management (admin)
A structured form (`frontend/src/admin/AssessmentsView.jsx`) — id, title, type, duration, hint
policy, and a raw JSON `pool_spec` textarea. Not a drag-and-drop authoring tool, per the explicit
scope line; the JSON textarea is honest about what's actually being edited rather than hiding it
behind partial UI that can't express every pool shape. Editing a definition's
duration/hint_policy/pool_spec/completion_rule bumps its `version` automatically
(`admin-learning-routes.js` diffs the incoming fields against the current row) — cosmetic edits
(title, description) don't. Create/edit is admin/owner only server-side; assign-to-cohort and
session view/cancel are available to a scoped `instructor` too, matching the RBAC matrix (§18).
## 16. Versioning
Deliberately minimal, per the explicit "don't overbuild full CMS versioning" instruction:
- **Curriculum path**: a plain `"version": 1` integer in `rootnode-path.json` itself, recorded
on `stage_completed`/`curriculum_completed` achievements as a snapshot.
- **Assessment definitions**: a plain incrementing `version` integer, bumped only on a semantic
change (duration/hint_policy/pool_spec/completion_rule — anything that would change what a
session is graded against). `assessment_sessions.definition_version` snapshots which version
was live at draw time, alongside a full redundant snapshot of the fields themselves
(title/duration/hint_policy) — so a later definition edit, or even deletion, can never change
what an already-started session is measured against.
No full version-history table exists for either — if that's ever needed, `version` is already a
column to build on, not a retrofit.
## 17. RBAC matrix (Phase 3B additions)
| Action | owner | admin | instructor | student |
|---|---|---|---|---|
| Create/edit assessment definitions | ✅ | ✅ | ❌ | ❌ |
| Assign an assessment to a cohort | ✅ | ✅ | ✅ (own cohorts) | ❌ |
| View/cancel assessment sessions | ✅ (any) | ✅ (any) | ✅ (own cohorts) | ❌ |
| View cohort learning summary / needs-attention / scenario analytics | ✅ (global) | ✅ (global) | ✅ (own cohorts) | ❌ |
| View a student's learning detail | ✅ | ✅ | ✅ (if enrolled in an owned cohort) | own only |
| Start/work an assessment, view own attempts/hints/achievements | ❌ | ❌ | ❌ | ✅ (own only) |
Enforced entirely server-side, reusing Phase 3A's `rbac.js` (`requireAdminOrOwner`,
`requireAnyInstructor`, `requireCohortAccess`, `requireStudentCohortAccess`) — no new
authorization primitives were needed, only new routes wired through the existing ones.
## 18. Security / integrity
- **Timer is server-authoritative** (§6) — `expires_at` fixed at draw time, never accepts a
client value, checked lazily on every touch.
- **Scenario draw is server-stored** — `assessment_session_scenarios` is the only source of
truth for what a student is supposed to be working on; nothing the client sends can add,
remove, or reorder it.
- **A student cannot submit/read another student's session** — every assessment route filters
by `student_id = req.studentId` server-side; a foreign session id returns 404, not 403 (same
"don't confirm existence" posture as Phase 3A's RBAC denials) — verified by an explicit test.
- **Gateway ownership** (Phase 2A/2B/3A) is unchanged and still enforced on every proxied Lab
request, HTTP and WebSocket, including the new `/start` and `/hint` relays.
- **Lab recreation never resets attempt/exam state** (§9) — verified live.
- **Hidden solution/pool data**: undrawn pool alternatives are never returned to the student
(only the actual draw); hints beyond the currently-unlocked level are never sent (§3, the
actual mechanism, not just a UI convention).
- **No invasive anti-cheat** — no browser surveillance, no lockdown mode. Explicitly out of
scope, per the non-goals list.
## 19. Dev/test commands
```bash
cd platform && docker compose -f docker-compose.dev.yml up -d
(cd api && PLATFORM_DB_URL=postgres://postgres:postgres@localhost:5433/rootnode_platform \
node bootstrap-owner.js [email protected] "Your Name" "a-real-password-12-chars-plus")
OWNER_EMAIL=[email protected] OWNER_PASSWORD=... node test/e2e-phase2a.js
OWNER_EMAIL=[email protected] OWNER_PASSWORD=... node test/e2e-phase2b.js
OWNER_EMAIL=[email protected] OWNER_PASSWORD=... node test/e2e-phase3a.js
OWNER_EMAIL=[email protected] OWNER_PASSWORD=... node test/e2e-phase3b.js
```
`e2e-phase3b.js` additionally shells out to `docker compose restart api` for its restart-
persistence case (set `SKIP_RESTART_TEST=1` to skip that one case if running somewhere that
isn't safe to restart).
## 20. Known limitations
- **No automatic "abandoned" detection** — an attempt a student never returns to stays
`in_progress` forever; there's no reaper tying Lab destruction back to attempt state. A
documented, deliberate scope line (would need wiring to Provisioner `environment_events`,
more machinery than the instructional-visibility goal needs).
- **`cohort_instructors.role_in_cohort`** (Phase 3A) still isn't consulted for finer-grained
assessment permissions — any cohort assignment grants the full scoped-instructor action set.
- **Needs-attention thresholds are fixed constants**, not per-cohort configurable yet.
- **No pagination** on the audit log (Phase 3A limitation, unchanged) or on scenario analytics.
- **Assessment pool_spec authoring is raw JSON** in the admin UI — correct and complete, but not
a guided form; a validation-assist UI is a natural but unbuilt next step.
- **One assessment at a time per student** — intentional (§6), but means a student can't have a
capstone and an exam "in flight" simultaneously even if that were ever desired.
## 21. Phase 4 boundary
Everything in this document runs on a single dev machine against
`platform/docker-compose.dev.yml`. **Not built, and explicitly out of scope this phase:**
production Hetzner/Proxmox deployment, production OIDC provider integration, a parent portal,
payments, public signup, automated email, equipment inventory, a multi-host scheduler,
Grafana/Prometheus production dashboards, public certificates, a marketplace/self-paced commerce
layer, the six held KubeKosh scenarios, invasive proctoring, and AI-driven grading. All of that
stays exactly where the Phase 3A doc's non-goals list and this document's §21 leave it — Phase 4
territory.
+2
View File
@@ -8,6 +8,7 @@ import ExamTimer from './components/ExamTimer'
import ExamReport from './components/ExamReport'
import ExamStartModal from './components/ExamStartModal'
import ExamHistory from './components/ExamHistory'
import AssessmentBanner from './components/AssessmentBanner'
import styles from './App.module.css'
const MIN_SIDEBAR_W = 180
@@ -303,6 +304,7 @@ export default function App() {
return (
<div className={styles.app}>
<Header clusterReady={clusterReady} onShowHistory={() => setShowHistory(true)} />
<AssessmentBanner />
{/* Bundle navigation bar */}
<BundleNav
+8
View File
@@ -6,16 +6,22 @@ import StudentsView from './StudentsView'
import InstructorsView from './InstructorsView'
import AuditView from './AuditView'
import AccountView from './AccountView'
import LearningView from './LearningView'
import AssessmentsView from './AssessmentsView'
import styles from './Admin.module.css'
// Instructor/Admin portal — clearly separate from the student experience (/admin route,
// separate rn_instructor session cookie, separate login). A student session cannot reach any
// of these views or their underlying /api/admin/* routes. Per-person accounts replace Phase
// 2B's single shared ADMIN_PASSWORD — see docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md.
// Learning/Assessments (Phase 3B) are visible to every instructor role — cohort scoping for a
// plain 'instructor' happens server-side per route, not by hiding the whole tab.
const NAV = [
{ key: 'dashboard', label: 'Dashboard' },
{ key: 'cohorts', label: 'Cohorts' },
{ key: 'students', label: 'Students' },
{ key: 'learning', label: 'Learning' },
{ key: 'assessments', label: 'Assessments' },
{ key: 'instructors', label: 'Instructors', roles: ['owner'] },
{ key: 'audit', label: 'Audit', roles: ['owner', 'admin'] },
]
@@ -53,6 +59,8 @@ export default function AdminPortal() {
{view === 'dashboard' && <AdminDashboard />}
{view === 'cohorts' && <CohortsView role={instructor.role} />}
{view === 'students' && <StudentsView role={instructor.role} />}
{view === 'learning' && <LearningView />}
{view === 'assessments' && <AssessmentsView role={instructor.role} />}
{view === 'instructors' && instructor.role === 'owner' && <InstructorsView currentInstructorId={instructor.instructor_id} />}
{view === 'audit' && ['owner', 'admin'].includes(instructor.role) && <AuditView />}
{view === 'account' && <AccountView instructor={instructor} />}
+164
View File
@@ -0,0 +1,164 @@
import { useState, useEffect, useCallback } from 'react'
import { adminApi } from './api'
import styles from './Admin.module.css'
// Exam and Capstone definition/session management. Definition authoring (create/edit) is
// admin/owner only server-side (RBAC) — a plain instructor can still assign an existing
// definition to their own cohort and view/cancel sessions within it, so the form below is
// always shown but the server will 403 a create/edit attempt from an instructor role; this
// mirrors CohortsView's approach of not pre-emptively hiding every control, since assessments
// are viewed by all roles and only definition-authoring is restricted.
export default function AssessmentsView({ role }) {
const [assessments, setAssessments] = useState(null)
const [cohorts, setCohorts] = useState(null)
const [selected, setSelected] = useState(null)
const [error, setError] = useState(null)
const canAuthor = role === 'owner' || role === 'admin'
const reload = useCallback(() => {
adminApi.assessments().then(setAssessments).catch((e) => setError(e.message))
adminApi.cohorts().then(setCohorts).catch(() => {})
}, [])
useEffect(reload, [reload])
if (selected) return <AssessmentDetail definition={selected} cohorts={cohorts} onBack={() => { setSelected(null); reload() }} />
return (
<>
{error && <div className={styles.errorBox}>{error}</div>}
{canAuthor && (
<div className={styles.card}>
<h2>Create assessment</h2>
<CreateAssessmentForm onCreated={reload} />
</div>
)}
<div className={styles.card}>
<h2>Exams &amp; Capstones</h2>
{!assessments ? <p className={styles.muted}>Loading…</p> : (
<table>
<thead><tr><th>Title</th><th>Type</th><th>Duration</th><th>Hints</th><th>Status</th><th /></tr></thead>
<tbody>
{assessments.map((a) => (
<tr key={a.assessment_definition_id}>
<td>{a.title}</td>
<td><span className={styles.pill}>{a.type}</span></td>
<td>{a.duration_minutes} min</td>
<td>{a.hint_policy}</td>
<td><span className={`${styles.pill} ${a.status === 'active' ? styles.pillOk : styles.pillMuted}`}>{a.status}</span></td>
<td><button className={styles.linkBtn} onClick={() => setSelected(a)}>Open</button></td>
</tr>
))}
</tbody>
</table>
)}
</div>
</>
)
}
function CreateAssessmentForm({ onCreated }) {
const [form, setForm] = useState({ id: '', type: 'exam', title: '', duration_minutes: 60, hint_policy: 'disabled', pool_spec: '' })
const [error, setError] = useState(null)
const [busy, setBusy] = useState(false)
const submit = async (e) => {
e.preventDefault()
setBusy(true); setError(null)
try {
const pool_spec = JSON.parse(form.pool_spec || '{"required":[],"random":[]}')
await adminApi.createAssessment({ ...form, duration_minutes: Number(form.duration_minutes), pool_spec })
setForm({ id: '', type: 'exam', title: '', duration_minutes: 60, hint_policy: 'disabled', pool_spec: '' })
onCreated()
} catch (e) { setError(e.message?.includes('JSON') ? 'pool_spec must be valid JSON' : e.message) } finally { setBusy(false) }
}
return (
<form onSubmit={submit}>
{error && <div className={styles.errorBox}>{error}</div>}
<div className={styles.formRow}>
<div><label className={styles.label}>ID (slug)</label>
<input className={styles.input} value={form.id} onChange={(e) => setForm({ ...form, id: e.target.value })} required /></div>
<div><label className={styles.label}>Title</label>
<input className={styles.input} value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} required /></div>
<div><label className={styles.label}>Type</label>
<select className={styles.select} value={form.type} onChange={(e) => setForm({ ...form, type: e.target.value })}>
<option value="exam">exam</option><option value="capstone">capstone</option>
</select></div>
<div><label className={styles.label}>Duration (min)</label>
<input className={styles.input} type="number" value={form.duration_minutes} onChange={(e) => setForm({ ...form, duration_minutes: e.target.value })} required /></div>
<div><label className={styles.label}>Hint policy</label>
<select className={styles.select} value={form.hint_policy} onChange={(e) => setForm({ ...form, hint_policy: e.target.value })}>
<option value="disabled">disabled</option><option value="flagged">flagged</option><option value="free">free</option>
</select></div>
</div>
<label className={styles.label}>Pool spec (JSON)</label>
<textarea className={styles.input} style={{ width: '100%', fontFamily: 'var(--mono)', minHeight: '4rem' }}
placeholder='{"required":["scenario-id"],"random":[{"count":1,"from":["a","b"]}]}'
value={form.pool_spec} onChange={(e) => setForm({ ...form, pool_spec: e.target.value })} required />
<button className={styles.btn} style={{ marginTop: '0.75rem' }} disabled={busy}>{busy ? 'Creating…' : 'Create'}</button>
</form>
)
}
function AssessmentDetail({ definition, cohorts, onBack }) {
const [sessions, setSessions] = useState(null)
const [error, setError] = useState(null)
const [notice, setNotice] = useState(null)
const [cohortId, setCohortId] = useState(cohorts?.[0]?.cohort_id || '')
const [busy, setBusy] = useState(false)
const reload = useCallback(() => {
adminApi.assessmentSessions(definition.assessment_definition_id).then(setSessions).catch((e) => setError(e.message))
}, [definition])
useEffect(reload, [reload])
const assign = async () => {
setBusy(true); setError(null); setNotice(null)
try { await adminApi.assignAssessment(definition.assessment_definition_id, cohortId); setNotice('Assigned.') }
catch (e) { setError(e.message) } finally { setBusy(false) }
}
const cancelSession = async (sessionId) => {
if (!window.confirm('Cancel this session?')) return
try { await adminApi.cancelAssessmentSession(sessionId); reload() } catch (e) { setError(e.message) }
}
return (
<>
<button className={styles.linkBtn} onClick={onBack}>&larr; Back to assessments</button>
<h2 style={{ margin: '1rem 0' }}>{definition.title} <span className={styles.muted}>({definition.type})</span></h2>
{error && <div className={styles.errorBox}>{error}</div>}
{notice && <p className={styles.muted}>{notice}</p>}
<div className={styles.card}>
<h3>Assign to cohort</h3>
<div className={styles.formRow}>
<div><select className={styles.select} value={cohortId} onChange={(e) => setCohortId(e.target.value)}>
{cohorts?.map((c) => <option key={c.cohort_id} value={c.cohort_id}>{c.name}</option>)}
</select></div>
<div><button className={styles.btn} disabled={busy || !cohortId} onClick={assign}>Assign</button></div>
</div>
</div>
<div className={styles.card}>
<h3>Sessions</h3>
{!sessions ? <p className={styles.muted}>Loading…</p> : sessions.length === 0 ? <p className={styles.muted}>None yet.</p> : (
<table>
<thead><tr><th>Student</th><th>Status</th><th>Started</th><th>Result</th><th /></tr></thead>
<tbody>
{sessions.map((s) => (
<tr key={s.assessment_session_id}>
<td>{s.display_name}</td>
<td><span className={`${styles.pill} ${s.status === 'completed' ? styles.pillOk : s.status === 'expired' ? styles.pillErr : styles.pillWarn}`}>{s.status}</span></td>
<td>{s.started_at ? new Date(s.started_at).toLocaleString() : '—'}</td>
<td>{s.result ? `${s.result.scenarios_passed}/${s.result.scenarios_total}` : '—'}</td>
<td>{['not_started', 'in_progress'].includes(s.status) && <button className={styles.linkBtn} onClick={() => cancelSession(s.assessment_session_id)}>Cancel</button>}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</>
)
}
+160
View File
@@ -0,0 +1,160 @@
import { useState, useEffect, useCallback } from 'react'
import { adminApi } from './api'
import styles from './Admin.module.css'
// Instructor learning analytics — cohort summary, "needs attention," per-scenario analytics,
// and a student drill-down. Deliberately no leaderboards or scoring language — this is
// instructional visibility, not a ranking (docs/PHASE3B_LEARNING_AND_ASSESSMENT.md §13).
export default function LearningView() {
const [cohorts, setCohorts] = useState(null)
const [cohortId, setCohortId] = useState(null)
const [summary, setSummary] = useState(null)
const [needsAttention, setNeedsAttention] = useState(null)
const [scenarioStats, setScenarioStats] = useState(null)
const [selectedStudent, setSelectedStudent] = useState(null)
const [error, setError] = useState(null)
useEffect(() => { adminApi.cohorts().then((c) => { setCohorts(c); if (c[0]) setCohortId(c[0].cohort_id) }).catch((e) => setError(e.message)) }, [])
const reload = useCallback(() => {
if (!cohortId) return
adminApi.cohortLearning(cohortId).then(setSummary).catch((e) => setError(e.message))
adminApi.needsAttention(cohortId).then(setNeedsAttention).catch((e) => setError(e.message))
adminApi.scenarioAnalytics(cohortId).then(setScenarioStats).catch((e) => setError(e.message))
}, [cohortId])
useEffect(reload, [reload])
if (selectedStudent) return <StudentLearningDetail studentId={selectedStudent} onBack={() => setSelectedStudent(null)} />
return (
<>
{error && <div className={styles.errorBox}>{error}</div>}
<div className={styles.card}>
<label className={styles.label}>Cohort</label>
<select className={styles.select} value={cohortId || ''} onChange={(e) => setCohortId(e.target.value)}>
{cohorts?.map((c) => <option key={c.cohort_id} value={c.cohort_id}>{c.name}</option>)}
</select>
</div>
{summary && (
<div className={styles.card}>
<h2>Cohort Summary</h2>
<div className={styles.statGrid}>
<div className={styles.statCard}><div className={styles.statValue}>{summary.student_count}</div><div className={styles.statLabel}>Students</div></div>
<div className={styles.statCard}><div className={styles.statValue}>{summary.active_learners}</div><div className={styles.statLabel}>Active (7d)</div></div>
<div className={styles.statCard}><div className={styles.statValue}>{summary.overall_completion_pct}%</div><div className={styles.statLabel}>Avg. Completion</div></div>
</div>
<table>
<thead><tr><th>Stage</th><th>Students Complete</th></tr></thead>
<tbody>
{summary.stages.map((s) => (
<tr key={s.id}><td>{s.name}</td><td>{s.completedBy}/{summary.student_count} ({s.pct}%)</td></tr>
))}
</tbody>
</table>
</div>
)}
<div className={styles.card}>
<h2>Needs Attention</h2>
{!needsAttention ? <p className={styles.muted}>Loading…</p> : needsAttention.length === 0 ? <p className={styles.muted}>No signals right now.</p> : (
<table>
<thead><tr><th>Student</th><th>Signal</th><th>Detail</th><th /></tr></thead>
<tbody>
{needsAttention.map((s, i) => (
<tr key={i}>
<td>{s.display_name}</td>
<td><span className={`${styles.pill} ${styles.pillWarn}`}>{s.label}</span></td>
<td className={styles.muted}>{s.detail}</td>
<td><button className={styles.linkBtn} onClick={() => setSelectedStudent(s.student_id)}>View</button></td>
</tr>
))}
</tbody>
</table>
)}
</div>
<div className={styles.card}>
<h2>Scenario Analytics</h2>
{!scenarioStats ? <p className={styles.muted}>Loading…</p> : (
<table>
<thead><tr><th>Scenario</th><th>Completions</th><th>Avg Attempts</th><th>Failures</th><th>Hints Used</th><th>Avg Time</th></tr></thead>
<tbody>
{scenarioStats.map((s) => (
<tr key={s.scenario_id}>
<td>{s.scenario_id}</td><td>{s.completions}</td><td>{s.avg_attempts}</td>
<td>{s.total_failures}</td><td>{s.total_hints_used}</td>
<td>{s.avg_duration_seconds ? `${Math.round(s.avg_duration_seconds / 60)}m` : '—'}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</>
)
}
function StudentLearningDetail({ studentId, onBack }) {
const [data, setData] = useState(null)
const [error, setError] = useState(null)
useEffect(() => { adminApi.studentLearning(studentId).then(setData).catch((e) => setError(e.message)) }, [studentId])
if (error) return <div className={styles.errorBox}>{error}</div>
if (!data) return <p className={styles.muted}>Loading…</p>
return (
<>
<button className={styles.linkBtn} onClick={onBack}>&larr; Back</button>
<h2 style={{ margin: '1rem 0' }}>{studentId}</h2>
{data.curriculum && (
<div className={styles.card}>
<h3>Curriculum ({data.curriculum.percent_complete}%)</h3>
{data.curriculum.stages.map((s) => (
<div key={s.id} style={{ fontSize: '0.85rem', padding: '0.2rem 0' }}>
{s.complete ? '✅' : s.locked ? '🔒' : '🟡'} {s.name} — {s.completed}/{s.total}
</div>
))}
</div>
)}
<div className={styles.card}>
<h3>Attempts</h3>
<table>
<thead><tr><th>Scenario</th><th>Status</th><th>Validations</th><th>Hints (max level)</th><th>Duration</th></tr></thead>
<tbody>
{data.attempts.map((a, i) => (
<tr key={i}>
<td>{a.scenario_id}</td>
<td><span className={`${styles.pill} ${a.status === 'completed' ? styles.pillOk : styles.pillWarn}`}>{a.status}</span></td>
<td>{a.validation_pass_count} passed, {a.validation_fail_count} failed</td>
<td>{a.hint_count} ({a.highest_hint_level})</td>
<td>{a.duration_seconds ? `${Math.round(a.duration_seconds / 60)}m` : '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className={styles.card}>
<h3>Assessment History</h3>
{data.assessment_sessions.length === 0 ? <p className={styles.muted}>None yet.</p> : (
<table>
<thead><tr><th>Assessment</th><th>Status</th><th>Result</th></tr></thead>
<tbody>
{data.assessment_sessions.map((s) => (
<tr key={s.assessment_session_id}>
<td>{s.title}</td>
<td><span className={`${styles.pill} ${s.status === 'completed' ? styles.pillOk : styles.pillMuted}`}>{s.status}</span></td>
<td>{s.result ? `${s.result.scenarios_passed}/${s.result.scenarios_total}` : '—'}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</>
)
}
+14
View File
@@ -80,4 +80,18 @@ export const adminApi = {
resetInstructorPassword: (id, new_password) => post(`/api/admin/instructors/${id}/reset-password`, { new_password }),
auditEvents: () => get('/api/admin/audit-events'),
// ── Phase 3B: learning analytics ────────────────────────────────────────────────────────
cohortLearning: (id) => get(`/api/admin/cohorts/${id}/learning`),
studentLearning: (id) => get(`/api/admin/students/${id}/learning`),
scenarioAnalytics: (cohortId) => get(`/api/admin/scenario-analytics${cohortId ? `?cohort_id=${cohortId}` : ''}`),
needsAttention: (cohortId) => get(`/api/admin/cohorts/${cohortId}/needs-attention`),
// ── Phase 3B: assessment (exam + capstone) management ───────────────────────────────────
assessments: () => get('/api/admin/assessments'),
createAssessment: (fields) => post('/api/admin/assessments', fields),
updateAssessment: (id, fields) => patch(`/api/admin/assessments/${id}`, fields),
assignAssessment: (id, cohort_id) => post(`/api/admin/assessments/${id}/assign`, { cohort_id }),
assessmentSessions: (id) => get(`/api/admin/assessments/${id}/sessions`),
cancelAssessmentSession: (sessionId) => post(`/api/admin/assessment-sessions/${sessionId}/cancel`),
}
@@ -0,0 +1,47 @@
import { useState, useEffect } from 'react'
// Phase 3B — shows when the student has an active Platform-level assessment (exam or capstone)
// running, distinct from the pre-existing Lab-local exam mode (ExamTimer/ExamSession above,
// which predates this and stays untouched). Polls /api/assessments/active, which 404s/errors
// harmlessly in standalone/single-container dev mode (no Platform API there at all) — so this
// component renders nothing in that mode, by design.
export default function AssessmentBanner() {
const [active, setActive] = useState(null)
const [remaining, setRemaining] = useState(null)
useEffect(() => {
let cancelled = false
function poll() {
fetch('/api/assessments/active').then((r) => r.json()).then((d) => {
if (!cancelled) setActive(d.active ? d : null)
}).catch(() => { if (!cancelled) setActive(null) })
}
poll()
const id = setInterval(poll, 15000)
return () => { cancelled = true; clearInterval(id) }
}, [])
useEffect(() => {
if (!active) return
const tick = () => setRemaining(Math.max(0, new Date(active.expires_at) - Date.now()))
tick()
const id = setInterval(tick, 1000)
return () => clearInterval(id)
}, [active])
if (!active) return null
const m = Math.floor(remaining / 60000), s = Math.floor((remaining % 60000) / 1000)
const completedCount = active.scenarios.filter((sc) => sc.status === 'completed').length
return (
<div style={{
display: 'flex', alignItems: 'center', gap: '1rem', padding: '0.5rem 1rem',
background: 'var(--amber-dim)', color: 'var(--amber)', fontSize: '0.85rem', fontFamily: 'var(--mono)',
borderBottom: '1px solid var(--border)',
}}>
<span>🎯 Assessment Mode: {active.title}</span>
<span>{completedCount}/{active.scenarios.length} scenarios complete</span>
<span>⏱ {m}m {s}s remaining</span>
</div>
)
}
@@ -0,0 +1,91 @@
import { useState, useEffect, useCallback } from 'react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import styles from './ScenarioPanel.module.css'
// Phase 3B progressive hints — used when the Gateway has stripped the full `hints` array from
// the scenario payload (the multi-student, Platform-mediated deployment; see
// platform/gateway/src/server.js's GET /lab/:token/api/scenarios/:scenarioId). Hints are
// fetched one at a time from the Platform API, server-enforced sequential (can't skip to hint
// 3), and every reveal is recorded against the student's attempt — none of that is possible
// with the old fully-preloaded array this replaces for this deployment mode.
//
// In standalone/single-container dev mode there's no Gateway rewrite, so `scenario.hints` is
// still the full array and ScenarioPanel never renders this component at all — see the
// `hintCount != null` check at the call site.
export default function ProgressiveHints({ scenarioId, totalHints }) {
const [revealed, setRevealed] = useState([]) // [{level, title, body, command}]
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
const [copiedIdx, setCopiedIdx] = useState(null)
const [assessmentHintPolicy, setAssessmentHintPolicy] = useState(null) // null = not in an assessment; 'free'|'disabled'|'flagged'
// A scenario/environment change resets local reveal state — the server is still the source
// of truth (GET /attempt would show the real highest_hint_level), this is just what's
// rendered locally this session. Also checks whether this scenario is part of an active
// Platform assessment, purely to show the policy indicator pre-emptively (the server enforces
// the policy regardless, on every reveal call — this is UX, not the security boundary).
useEffect(() => {
setRevealed([]); setError(null); setAssessmentHintPolicy(null)
fetch('/api/assessments/active').then((r) => r.json()).then((d) => {
if (d.active && d.scenarios?.some((s) => s.scenario_id === scenarioId)) setAssessmentHintPolicy(d.hint_policy)
}).catch(() => {})
}, [scenarioId])
const revealNext = useCallback(async () => {
setLoading(true); setError(null)
try {
const r = await fetch(`api/scenarios/${scenarioId}/hint`, { method: 'POST' })
const d = await r.json()
if (!r.ok) { setError(d.message || d.error || 'Could not reveal a hint.'); return }
setRevealed((h) => [...h, { level: d.level, ...d.hint }])
} catch {
setError('Could not reach the platform to reveal a hint.')
} finally {
setLoading(false)
}
}, [scenarioId])
function copyCmd(cmd, idx) {
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(cmd).then(() => { setCopiedIdx(idx); setTimeout(() => setCopiedIdx(null), 1800) })
}
}
if (totalHints === 0) return <div className={styles.noHints}>No hints available for this scenario.</div>
if (assessmentHintPolicy === 'disabled' && revealed.length === 0) {
return <div className={styles.noHints}>Hints Disabled — this assessment does not allow hints.</div>
}
return (
<div>
{assessmentHintPolicy === 'flagged' && <div className={styles.setupNote} style={{ marginBottom: '0.75rem' }}>Using a hint will be recorded.</div>}
{revealed.map((hint, i) => (
<div key={i} className={styles.hintCard}>
<div className={styles.hintHeader}>
<div className={styles.hintLeft}>
<span className={styles.hintNum}>Hint {hint.level}</span>
<span className={styles.hintTitle}>{hint.title}</span>
</div>
</div>
<div className={styles.hintBody}>
<p className={styles.hintText}><ReactMarkdown remarkPlugins={[remarkGfm]}>{hint.body}</ReactMarkdown></p>
{hint.command && (
<div className={styles.cmdBlock}>
<pre className={styles.cmdPre}>{hint.command}</pre>
<button className={styles.copyBtn} onClick={() => copyCmd(hint.command, i)}>{copiedIdx === i ? '✓ Copied' : 'Copy'}</button>
</div>
)}
</div>
</div>
))}
{error && <div className={styles.validateError}>⚠ {error}</div>}
{revealed.length < totalHints && (
<button className={styles.setupBtn} disabled={loading} onClick={revealNext}>
{loading ? 'Revealing…' : `Reveal Hint ${revealed.length + 1} of ${totalHints}`}
</button>
)}
</div>
)
}
+22 -1
View File
@@ -3,6 +3,7 @@ import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import styles from './ScenarioPanel.module.css'
import { useConfirm } from './useConfirm'
import ProgressiveHints from './ProgressiveHints'
// Inline markdown: renders without a wrapping <p> — safe for buttons/spans
const inlineComponents = {
@@ -48,6 +49,15 @@ export default function ScenarioPanel({ scenario, onProgressUpdate, onScenarioSt
setHintsRevealed([])
}, [scenario?.id])
// Phase 3B attempt telemetry — fire-and-forget "scenario started" so the Platform can record
// a real started_at for this working session. Only meaningful under the Platform-multiplied
// deployment (routed through the Gateway); 404s harmlessly in standalone/single-container dev
// mode, where there's no Platform API to reach at all.
useEffect(() => {
if (!scenario?.id) return
fetch(`api/scenarios/${scenario.id}/start`, { method: 'POST' }).catch(() => {})
}, [scenario?.id])
const timeSpentRef = useRef(0)
const lastScenarioIdRef = useRef(null)
@@ -341,7 +351,7 @@ export default function ScenarioPanel({ scenario, onProgressUpdate, onScenarioSt
onClick={() => setTab(t)}
>
{t === 'problem' ? '📄 Problem'
: t === 'hints' ? `💡 Hints (${scenario.hints?.length || 0})`
: t === 'hints' ? `💡 Hints (${scenario.hints?.length ?? scenario.hint_count ?? 0})`
: '✓ Validate'}
</button>
))}
@@ -448,6 +458,15 @@ export default function ScenarioPanel({ scenario, onProgressUpdate, onScenarioSt
{/* HINTS TAB */}
{tab === 'hints' && (
<div className={styles.tabPane} style={{ animation: 'fadeIn 0.2s ease' }}>
{/* Phase 3B: the Gateway strips `hints` from the payload under the Platform-
multiplied deployment (see platform/gateway/src/server.js) — hint_count survives
as the signal to switch to server-gated progressive reveal instead of the old
fully-preloaded array. Standalone/dev mode still has the full array (no Gateway
rewrite happens there) and keeps the original behavior untouched below. */}
{scenario.hints === undefined && typeof scenario.hint_count === 'number' ? (
<ProgressiveHints scenarioId={scenario.id} totalHints={scenario.hint_count} />
) : (
<>
{scenario.hints?.length === 0 && (
<div className={styles.noHints}>No hints available for this scenario.</div>
)}
@@ -483,6 +502,8 @@ export default function ScenarioPanel({ scenario, onProgressUpdate, onScenarioSt
</div>
)
})}
</>
)}
</div>
)}
+40
View File
@@ -0,0 +1,40 @@
import { useState, useEffect } from 'react'
import { api } from './api'
import styles from './Platform.module.css'
const LABELS = {
stage_completed: 'Stage completed', curriculum_completed: 'Curriculum completed',
assessment_completed: 'Assessment completed', capstone_completed: 'Capstone completed',
}
const ICONS = {
stage_completed: '✅', curriculum_completed: '🎓', assessment_completed: '📝', capstone_completed: '🏁',
}
// Not a public certificate — a durable internal record. Phase 4/5 can render a certificate from
// these later (docs/PHASE3B_LEARNING_AND_ASSESSMENT.md §16); nothing here is shareable yet.
export default function AchievementsTab() {
const [achievements, setAchievements] = useState(null)
const [error, setError] = useState(null)
useEffect(() => {
api.achievements().then(setAchievements).catch((e) => setError(e.message))
}, [])
return (
<section className={styles.card}>
<h2>Achievements &amp; History</h2>
{error && <div className={styles.errorBox}>{error}</div>}
{!achievements ? <p className={styles.muted}>Loading…</p> : achievements.length === 0 ? <p className={styles.muted}>Nothing yet — complete a stage to see it here.</p> : (
achievements.map((a) => (
<div key={a.achievement_id} style={{ display: 'flex', alignItems: 'center', gap: '0.6rem', padding: '0.5rem 0', borderBottom: '1px solid var(--border)' }}>
<span style={{ fontSize: '1.2rem' }}>{ICONS[a.achievement_type] || '🏅'}</span>
<div>
<div>{LABELS[a.achievement_type] || a.achievement_type}{a.stage_id ? `: ${a.stage_id}` : ''}{a.metadata?.title ? `: ${a.metadata.title}` : ''}</div>
<div className={styles.muted} style={{ fontSize: '0.78rem' }}>{new Date(a.achieved_at).toLocaleString()}</div>
</div>
</div>
))
)}
</section>
)
}
+113
View File
@@ -0,0 +1,113 @@
import { useState, useEffect, useCallback } from 'react'
import { api } from './api'
import styles from './Platform.module.css'
// Available exams (assigned to the student's cohort), an active session if one is running (with
// a live countdown), and completed history. Starting an exam draws its scenario set
// server-side — the student works through it inside their existing Lab, the same one My
// Learning launches (see docs/PHASE3B_LEARNING_AND_ASSESSMENT.md §9 for why a separate Lab
// per assessment isn't needed).
export default function AssessmentsTab() {
const [assessments, setAssessments] = useState(null)
const [active, setActive] = useState(null)
const [error, setError] = useState(null)
const [busy, setBusy] = useState(false)
const reload = useCallback(() => {
api.assessments().then(setAssessments).catch((e) => setError(e.message))
api.activeAssessment().then(setActive).catch(() => {})
}, [])
useEffect(reload, [reload])
const handleStart = async (definitionId) => {
setBusy(true); setError(null)
try { await api.startAssessment(definitionId); reload() }
catch (e) { setError(e.message) } finally { setBusy(false) }
}
return (
<>
{error && <div className={styles.errorBox}>{error}</div>}
{active?.active && (
<section className={styles.card}>
<h2>Active Assessment</h2>
<p><strong>{active.title}</strong></p>
<CountdownLine expiresAt={active.expires_at} />
<table className={styles.dataTable} style={{ marginTop: '0.75rem' }}>
<thead><tr><th>#</th><th>Scenario</th><th>Status</th></tr></thead>
<tbody>
{active.scenarios.map((s) => (
<tr key={s.scenario_id}>
<td>{s.position + 1}</td><td>{s.scenario_id}</td>
<td>
{s.status === 'completed' ? <span className={`${styles.pill} ${styles.pillOk}`}>completed</span>
: <span className={`${styles.pill} ${styles.pillMuted}`}>{s.status}</span>}
</td>
</tr>
))}
</tbody>
</table>
<p className={styles.hint} style={{ marginTop: '0.75rem' }}>Launch your Lab from My Learning and work through these scenarios there — this list stays in sync automatically.</p>
</section>
)}
<section className={styles.card}>
<h2>Available Assessments</h2>
{!assessments ? <p className={styles.muted}>Loading…</p> : assessments.length === 0 ? <p className={styles.muted}>None assigned to your cohort yet.</p> : (
assessments.map((a) => {
const completedSessions = a.sessions.filter((s) => s.status === 'completed')
return (
<div key={a.assessment_definition_id} style={{ padding: '0.75rem 0', borderBottom: '1px solid var(--border)' }}>
<p><strong>{a.title}</strong> <span className={styles.muted}>· {a.duration_minutes} min</span></p>
{a.description && <p className={styles.muted} style={{ fontSize: '0.85rem' }}>{a.description}</p>}
{completedSessions.length > 0 && (
<p className={styles.hint}>Completed {completedSessions.length}× — best result: {completedSessions.some((s) => s.result?.passed) ? 'passed' : 'not yet passed'}</p>
)}
{!active?.active && (
<button className={styles.secondaryBtn} disabled={busy} onClick={() => handleStart(a.assessment_definition_id)}>
{busy ? 'Starting…' : 'Start'}
</button>
)}
</div>
)
})
)}
</section>
<section className={styles.card}>
<h2>History</h2>
{!assessments ? <p className={styles.muted}>Loading…</p> : (
<table className={styles.dataTable}>
<thead><tr><th>Assessment</th><th>Started</th><th>Status</th><th>Result</th></tr></thead>
<tbody>
{assessments.flatMap((a) => a.sessions).filter((s) => s.status !== 'not_started').sort((a, b) => new Date(b.started_at) - new Date(a.started_at)).map((s) => (
<tr key={s.assessment_session_id}>
<td>{assessments.find((a) => a.sessions.includes(s))?.title}</td>
<td>{s.started_at ? new Date(s.started_at).toLocaleString() : '—'}</td>
<td><StatusPill status={s.status} /></td>
<td>{s.result ? `${s.result.scenarios_passed}/${s.result.scenarios_total}` : '—'}</td>
</tr>
))}
</tbody>
</table>
)}
</section>
</>
)
}
function StatusPill({ status }) {
const cls = status === 'completed' ? styles.pillOk : status === 'expired' || status === 'cancelled' ? styles.pillErr : styles.pillWarn
return <span className={`${styles.pill} ${cls}`}>{status}</span>
}
function CountdownLine({ expiresAt }) {
const [remaining, setRemaining] = useState(() => Math.max(0, new Date(expiresAt) - Date.now()))
useEffect(() => {
const id = setInterval(() => setRemaining(Math.max(0, new Date(expiresAt) - Date.now())), 1000)
return () => clearInterval(id)
}, [expiresAt])
const m = Math.floor(remaining / 60000), s = Math.floor((remaining % 60000) / 1000)
return <p className={styles.statusLine}><span className={styles.spinner} /> Time remaining: {m}m {s}s</p>
}
+89
View File
@@ -0,0 +1,89 @@
import { useState, useEffect, useCallback } from 'react'
import { api } from './api'
import styles from './Platform.module.css'
const STAGE_NAMES = {
foundation: 'Foundation', workloads: 'Workloads', networking: 'Networking',
configuration: 'Configuration', troubleshooting: 'Troubleshooting', 'real-ops': 'RootNode: Real Ops',
}
export default function CapstoneTab() {
const [capstone, setCapstone] = useState(null)
const [error, setError] = useState(null)
const [busy, setBusy] = useState(false)
const reload = useCallback(() => {
api.capstone().then(setCapstone).catch((e) => setError(e.message))
}, [])
useEffect(reload, [reload])
const handleStart = async () => {
setBusy(true); setError(null)
try { await api.startAssessment(capstone.assessment_definition_id); reload() }
catch (e) { setError(e.message) } finally { setBusy(false) }
}
if (error) return <div className={styles.errorBox}>{error}</div>
if (!capstone) return <p className={styles.muted}>Loading…</p>
if (capstone.status === 'locked') {
return (
<section className={styles.card}>
<h2>Capstone</h2>
<div className={styles.lockedBox}>
<p style={{ fontSize: '2rem' }}>🔒</p>
<p>Complete the rest of the curriculum to unlock the RootNode Capstone.</p>
</div>
<h3 style={{ marginTop: '1rem', fontSize: '0.9rem' }}>Prerequisites</h3>
{capstone.blocking_stage_ids.map((id) => (
<div key={id} className={styles.checklistItem}><span>⬜</span> {STAGE_NAMES[id] || id}</div>
))}
</section>
)
}
const activeSession = capstone.sessions.find((s) => s.status === 'in_progress');
const completedSession = capstone.sessions.find((s) => s.status === 'completed' && s.result?.passed);
return (
<section className={styles.card}>
<h2>Capstone {capstone.status === 'completed' ? <span className={`${styles.pill} ${styles.pillOk}`} style={{ marginLeft: '0.5rem' }}>completed</span> : <span className={`${styles.pill} ${styles.pillOk}`} style={{ marginLeft: '0.5rem' }}>unlocked</span>}</h2>
{capstone.definition && (
<>
<p><strong>{capstone.definition.title}</strong> <span className={styles.muted}>· {capstone.definition.duration_minutes} min</span></p>
<p className={styles.muted} style={{ fontSize: '0.85rem' }}>{capstone.definition.description}</p>
</>
)}
{completedSession ? (
<p className={styles.hint} style={{ marginTop: '1rem' }}>
Completed {new Date(completedSession.submitted_at).toLocaleString()} — {completedSession.result.scenarios_passed}/{completedSession.result.scenarios_total} scenarios passed.
</p>
) : activeSession ? (
<p className={styles.hint} style={{ marginTop: '1rem' }}>Your Capstone is in progress — launch your Lab from My Learning to continue.</p>
) : (
<button className={styles.primaryBtn} style={{ marginTop: '1rem' }} disabled={busy} onClick={handleStart}>
{busy ? 'Starting…' : 'Start Capstone'}
</button>
)}
{capstone.sessions.length > 0 && (
<>
<h3 style={{ marginTop: '1.5rem', fontSize: '0.9rem' }}>Attempt History</h3>
<table className={styles.dataTable}>
<thead><tr><th>Started</th><th>Status</th><th>Result</th></tr></thead>
<tbody>
{capstone.sessions.map((s) => (
<tr key={s.assessment_session_id}>
<td>{s.started_at ? new Date(s.started_at).toLocaleString() : '—'}</td>
<td>{s.status}</td>
<td>{s.result ? `${s.result.scenarios_passed}/${s.result.scenarios_total}` : '—'}</td>
</tr>
))}
</tbody>
</table>
</>
)}
</section>
)
}
+135
View File
@@ -0,0 +1,135 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { api } from './api'
import styles from './Platform.module.css'
const POLL_MS = 2500
// The original Phase 2A/2B "Up Next" + Launch Lab / Reset flow, now extended with overall
// percent-complete (Phase 3B's rule-driven curriculum state, GET /api/curriculum/state, instead
// of the older /api/curriculum/path stats-only shape — same stage list, richer per-stage data).
export default function MyLearningTab() {
const [curriculum, setCurriculum] = useState(null)
const [envStatus, setEnvStatus] = useState(null)
const [error, setError] = useState(null)
const [busy, setBusy] = useState(false)
const pollRef = useRef(null)
const loadCurriculum = useCallback(() => {
api.curriculumState().then(setCurriculum).catch((e) => setError(e.message))
}, [])
const loadEnvStatus = useCallback(() => {
api.environmentStatus().then(setEnvStatus).catch((e) => setError(e.message))
}, [])
useEffect(() => { loadCurriculum(); loadEnvStatus() }, [loadCurriculum, loadEnvStatus])
useEffect(() => {
const inFlight = envStatus && ['requested', 'provisioning', 'resetting'].includes(envStatus.status)
if (inFlight && !pollRef.current) {
pollRef.current = setInterval(loadEnvStatus, POLL_MS)
} else if (!inFlight && pollRef.current) {
clearInterval(pollRef.current)
pollRef.current = null
}
return () => { if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null } }
}, [envStatus, loadEnvStatus])
const handleLaunch = async () => {
setBusy(true); setError(null)
try {
const result = await api.launchEnvironment()
setEnvStatus(result)
if (result.status === 'running' && result.launch_path) window.location.href = result.launch_path
} catch (e) { setError(e.message) } finally { setBusy(false) }
}
const handleReset = async () => {
setBusy(true); setError(null)
try { setEnvStatus(await api.resetEnvironment()) } catch (e) { setError(e.message) } finally { setBusy(false) }
}
const currentStage = curriculum?.stages?.find((s) => s.id === curriculum.current_stage)
return (
<>
{error && <div className={styles.errorBox}>{error}</div>}
<section className={styles.card}>
<h2>Current Stage</h2>
{!curriculum ? <p className={styles.muted}>Loading curriculum…</p> : (
<>
<div style={{ marginBottom: '0.75rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.85rem', marginBottom: '0.3rem' }}>
<span>Overall progress</span><span className={styles.muted}>{curriculum.percent_complete}%</span>
</div>
<div className={styles.progressBarTrack}><div className={styles.progressBarFill} style={{ width: `${curriculum.percent_complete}%` }} /></div>
</div>
<div className={styles.stageRow}>
{curriculum.stages.map((s) => (
<div key={s.id} className={`${styles.stagePill} ${s.id === curriculum.current_stage ? styles.stagePillActive : ''} ${s.locked ? styles.stagePillLocked : ''}`}>
<span>{s.icon}</span> {s.name}
<span className={styles.stageCount}>{s.completed}/{s.total}</span>
</div>
))}
</div>
{currentStage ? (
<p className={styles.nextUp}>
Up next: <strong>{currentStage.name}</strong>
{currentStage.next_scenario && <> — {currentStage.next_scenario}</>}
</p>
) : (
<p className={styles.nextUp}>All curriculum stages complete — your Capstone is waiting.</p>
)}
</>
)}
</section>
<section className={styles.card}>
<h2>Your Lab</h2>
<EnvironmentPanel status={envStatus} busy={busy} onLaunch={handleLaunch} onReset={handleReset} />
</section>
</>
)
}
function EnvironmentPanel({ status, busy, onLaunch, onReset }) {
const s = status?.status || 'none'
if (s === 'none' || s === 'destroyed') {
return (
<>
<p className={styles.muted}>No lab running yet.</p>
<button className={styles.primaryBtn} disabled={busy} onClick={onLaunch}>
{busy ? 'Launching…' : 'Launch Lab'}
</button>
</>
)
}
if (['requested', 'provisioning', 'resetting'].includes(s)) {
return <p className={styles.statusLine}><span className={styles.spinner} /> {s === 'resetting' ? 'Recreating your lab…' : 'Starting your lab…'}</p>
}
if (s === 'running') {
return (
<>
<p className={styles.statusLine}><span className={styles.dotOk} /> Lab ready.</p>
<div className={styles.btnRow}>
<a className={styles.primaryBtn} href={`${status.launch_path}`}>Continue Lab</a>
<button className={styles.secondaryBtn} disabled={busy} onClick={onReset}>
{busy ? 'Working…' : 'Recreate Lab'}
</button>
</div>
<p className={styles.hint}>Recreating gives you a clean environment. Your progress is saved separately and is never lost.</p>
</>
)
}
if (s === 'error') {
return (
<>
<p className={styles.statusLine}><span className={styles.dotErr} /> {status.error_reason || 'Your lab hit a problem.'}</p>
<button className={styles.primaryBtn} disabled={busy} onClick={onLaunch}>
{busy ? 'Launching…' : 'Try Again'}
</button>
</>
)
}
return <p className={styles.muted}>Unknown state ({s}).</p>
}
+22
View File
@@ -159,3 +159,25 @@
animation: spin 0.7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* ── Learning tabs (Phase 3B) ─────────────────────────────────────────── */
.tabNav { display: flex; gap: 1.25rem; padding: 0 2rem; border-bottom: 1px solid var(--border); }
.tab { color: var(--text-2); cursor: pointer; font-size: 0.9rem; background: none; border: none; padding: 0.75rem 0; }
.tabActive { color: var(--green); border-bottom: 2px solid var(--green); }
.progressBarTrack { background: var(--surface3); border-radius: 4px; height: 8px; overflow: hidden; }
.progressBarFill { background: var(--green); height: 8px; }
table.dataTable { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
.dataTable th { text-align: left; color: var(--text-2); font-weight: 600; padding: 0.4rem 0.5rem; border-bottom: 1px solid var(--border2); font-size: 0.72rem; text-transform: uppercase; }
.dataTable td { padding: 0.5rem 0.5rem; border-bottom: 1px solid var(--border); }
.pill { display: inline-block; padding: 0.15rem 0.55rem; border-radius: 999px; font-size: 0.75rem; font-family: var(--mono); }
.pillOk { background: var(--green-dim); color: var(--green); }
.pillWarn { background: var(--amber-dim); color: var(--amber); }
.pillErr { background: var(--red-dim); color: var(--red); }
.pillMuted { background: var(--surface3); color: var(--text-2); }
.lockedBox { text-align: center; padding: 2rem 1rem; color: var(--text-2); }
.checklistItem { display: flex; align-items: center; gap: 0.5rem; padding: 0.35rem 0; font-size: 0.88rem; }
+77
View File
@@ -0,0 +1,77 @@
import { useState, useEffect } from 'react'
import { api } from './api'
import styles from './Platform.module.css'
// Available/completed scenarios (from curriculum state) + recent attempt history
// (GET /api/attempts/recent) — purely informational, no controls here; the actual work happens
// inside the Lab itself once launched from My Learning.
export default function PracticeTab() {
const [curriculum, setCurriculum] = useState(null)
const [attempts, setAttempts] = useState(null)
const [error, setError] = useState(null)
useEffect(() => {
api.curriculumState().then(setCurriculum).catch((e) => setError(e.message))
api.recentAttempts().then(setAttempts).catch((e) => setError(e.message))
}, [])
const allScenarios = curriculum?.stages.flatMap((s) => Array(s.total).fill(null).map((_, i) => i)) || []
const completedCount = curriculum?.stages.reduce((n, s) => n + s.completed, 0) || 0
const totalCount = allScenarios.length
return (
<>
{error && <div className={styles.errorBox}>{error}</div>}
<section className={styles.card}>
<h2>Scenarios</h2>
{!curriculum ? <p className={styles.muted}>Loading…</p> : (
<table className={styles.dataTable}>
<thead><tr><th>Stage</th><th>Completed</th><th>Status</th></tr></thead>
<tbody>
{curriculum.stages.map((s) => (
<tr key={s.id}>
<td>{s.icon} {s.name}</td>
<td>{s.completed}/{s.total}</td>
<td>
{s.locked ? <span className={`${styles.pill} ${styles.pillMuted}`}>locked</span>
: s.complete ? <span className={`${styles.pill} ${styles.pillOk}`}>complete</span>
: <span className={`${styles.pill} ${styles.pillWarn}`}>in progress</span>}
</td>
</tr>
))}
</tbody>
</table>
)}
{curriculum && <p className={styles.hint} style={{ marginTop: '0.75rem' }}>{completedCount}/{totalCount} scenarios completed overall.</p>}
</section>
<section className={styles.card}>
<h2>Recent Attempts</h2>
{!attempts ? <p className={styles.muted}>Loading…</p> : attempts.length === 0 ? <p className={styles.muted}>No attempts yet — launch your Lab and pick a scenario.</p> : (
<table className={styles.dataTable}>
<thead><tr><th>Scenario</th><th>Status</th><th>Validations</th><th>Hints</th><th>Duration</th></tr></thead>
<tbody>
{attempts.map((a, i) => (
<tr key={i}>
<td>{a.scenario_id}</td>
<td>
{a.status === 'completed' ? <span className={`${styles.pill} ${styles.pillOk}`}>completed</span>
: <span className={`${styles.pill} ${styles.pillWarn}`}>in progress</span>}
</td>
<td>{a.validation_pass_count} passed, {a.validation_fail_count} failed</td>
<td>{a.hint_count || 0}</td>
<td>{a.duration_seconds ? formatDuration(a.duration_seconds) : '—'}</td>
</tr>
))}
</tbody>
</table>
)}
</section>
</>
)
}
function formatDuration(seconds) {
const m = Math.floor(seconds / 60), s = seconds % 60
return `${m}m ${s}s`
}
+29 -139
View File
@@ -1,78 +1,27 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { useState } from 'react'
import { api } from './api'
import styles from './Platform.module.css'
import MyLearningTab from './MyLearningTab'
import PracticeTab from './PracticeTab'
import AssessmentsTab from './AssessmentsTab'
import CapstoneTab from './CapstoneTab'
import AchievementsTab from './AchievementsTab'
const POLL_MS = 2500
const TABS = [
{ key: 'learning', label: 'My Learning' },
{ key: 'practice', label: 'Practice' },
{ key: 'assessments', label: 'Assessments' },
{ key: 'capstone', label: 'Capstone' },
{ key: 'achievements', label: 'Achievements' },
]
// C/D/F. Student home ("Up Next") + Launch Lab / Reset flow.
// Uses the existing GET /api/curriculum/path — the RootNode path (Foundation -> Workloads ->
// Networking -> Configuration -> Troubleshooting -> Real Ops -> Capstone) is not reinvented
// here, just rendered.
// Student home — tabbed as of Phase 3B (docs/PHASE3B_LEARNING_AND_ASSESSMENT.md §10): My
// Learning (the original Phase 2A/2B "Up Next" + Lab launch flow, unchanged in behavior, just
// extracted into its own component), Practice, Assessments, Capstone, Achievements/History.
export default function StudentDashboard({ student, onLogout }) {
const [curriculum, setCurriculum] = useState(null)
const [envStatus, setEnvStatus] = useState(null) // { status, launch_path?, error_reason? }
const [error, setError] = useState(null)
const [busy, setBusy] = useState(false)
const pollRef = useRef(null)
const loadCurriculum = useCallback(() => {
api.curriculumPath().then(setCurriculum).catch((e) => setError(e.message))
}, [])
const loadEnvStatus = useCallback(() => {
api.environmentStatus().then(setEnvStatus).catch((e) => setError(e.message))
}, [])
useEffect(() => { loadCurriculum(); loadEnvStatus() }, [loadCurriculum, loadEnvStatus])
// Poll while an environment is in-flight (provisioning/resetting) so the UI transitions to
// ready/error on its own — matches requirement D.4: provisioning -> starting -> ready -> error.
useEffect(() => {
const inFlight = envStatus && ['requested', 'provisioning', 'resetting'].includes(envStatus.status)
if (inFlight && !pollRef.current) {
pollRef.current = setInterval(loadEnvStatus, POLL_MS)
} else if (!inFlight && pollRef.current) {
clearInterval(pollRef.current)
pollRef.current = null
}
return () => { if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null } }
}, [envStatus, loadEnvStatus])
const handleLaunch = async () => {
setBusy(true); setError(null)
try {
const result = await api.launchEnvironment()
setEnvStatus(result)
if (result.status === 'running' && result.launch_path) {
// No ?sid= needed anymore (Phase 2B) — the Gateway verifies ownership from the
// rn_session cookie itself, which travels with this navigation automatically. See
// docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md §3.
window.location.href = result.launch_path
}
} catch (e) {
setError(e.message)
} finally {
setBusy(false)
}
}
const handleReset = async () => {
setBusy(true); setError(null)
try {
const result = await api.resetEnvironment()
setEnvStatus(result)
} catch (e) {
setError(e.message)
} finally {
setBusy(false)
}
}
const [tab, setTab] = useState('learning')
const handleLogout = () => { api.logout().then(onLogout) }
const nextStage = curriculum?.stages?.find((s) => s.id === curriculum.next_stage)
const nextScenario = nextStage?.scenarios?.find((sc) => sc.status !== 'completed')
return (
<div className={styles.dashWrap}>
<header className={styles.dashHeader}>
@@ -83,78 +32,19 @@ export default function StudentDashboard({ student, onLogout }) {
</div>
</header>
{error && <div className={styles.errorBox}>{error}</div>}
<nav className={styles.tabNav}>
{TABS.map((t) => (
<button key={t.key} className={`${styles.tab} ${tab === t.key ? styles.tabActive : ''}`} onClick={() => setTab(t.key)}>
{t.label}
</button>
))}
</nav>
<section className={styles.card}>
<h2>Up Next</h2>
{!curriculum ? <p className={styles.muted}>Loading curriculum…</p> : (
<>
<div className={styles.stageRow}>
{curriculum.stages.map((s) => (
<div key={s.id} className={`${styles.stagePill} ${s.id === curriculum.next_stage ? styles.stagePillActive : ''} ${s.locked ? styles.stagePillLocked : ''}`}>
<span>{s.icon}</span> {s.name}
<span className={styles.stageCount}>{s.stats.completed}/{s.stats.total}</span>
</div>
))}
</div>
{nextStage && (
<p className={styles.nextUp}>
Next: <strong>{nextStage.name}</strong>
{nextScenario && <> — {nextScenario.title}</>}
</p>
)}
{!nextStage && <p className={styles.nextUp}>All unlocked stages complete — nice work.</p>}
</>
)}
</section>
<section className={styles.card}>
<h2>Your Lab</h2>
<EnvironmentPanel status={envStatus} busy={busy} onLaunch={handleLaunch} onReset={handleReset} />
</section>
{tab === 'learning' && <MyLearningTab />}
{tab === 'practice' && <PracticeTab />}
{tab === 'assessments' && <AssessmentsTab />}
{tab === 'capstone' && <CapstoneTab />}
{tab === 'achievements' && <AchievementsTab />}
</div>
)
}
function EnvironmentPanel({ status, busy, onLaunch, onReset }) {
const s = status?.status || 'none'
if (s === 'none' || s === 'destroyed') {
return (
<>
<p className={styles.muted}>No lab running yet.</p>
<button className={styles.primaryBtn} disabled={busy} onClick={onLaunch}>
{busy ? 'Launching…' : 'Launch Lab'}
</button>
</>
)
}
if (['requested', 'provisioning', 'resetting'].includes(s)) {
return <p className={styles.statusLine}><span className={styles.spinner} /> {s === 'resetting' ? 'Recreating your lab…' : 'Starting your lab…'}</p>
}
if (s === 'running') {
return (
<>
<p className={styles.statusLine}><span className={styles.dotOk} /> Lab ready.</p>
<div className={styles.btnRow}>
<a className={styles.primaryBtn} href={`${status.launch_path}`}>Continue Lab</a>
<button className={styles.secondaryBtn} disabled={busy} onClick={onReset}>
{busy ? 'Working…' : 'Recreate Lab'}
</button>
</div>
<p className={styles.hint}>Recreating gives you a clean environment. Your progress is saved separately and is never lost.</p>
</>
)
}
if (s === 'error') {
return (
<>
<p className={styles.statusLine}><span className={styles.dotErr} /> {status.error_reason || 'Your lab hit a problem.'}</p>
<button className={styles.primaryBtn} disabled={busy} onClick={onLaunch}>
{busy ? 'Launching…' : 'Try Again'}
</button>
</>
)
}
return <p className={styles.muted}>Unknown state ({s}).</p>
}
+13
View File
@@ -66,8 +66,21 @@ export const api = {
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),
}
+29 -18
View File
@@ -1,21 +1,27 @@
# RootNode Academy — Platform
The full multi-student platform, layered on top of the unmodified Lab image at the repo root.
Start here, then go to the component you need:
The full multi-student platform, layered on top of the Lab image at the repo root (backend/
entrypoint/Dockerfile unmodified; frontend bundle gained Phase 3B learning UX, see
`docs/PHASE3B_LEARNING_AND_ASSESSMENT.md` §10–11). Start here, then go to the component you need:
- `provisioner/` — Postgres-backed environment registry and lifecycle. Design:
`docs/LAB_ARCHITECTURE.md`, `docs/PHASE1_PLATFORM_FOUNDATION.md`. Operational reference:
`provisioner/README.md`.
- `api/` — the lightweight Platform API: students, cohorts, enrollments, curriculum, progress,
scenario attempts, per-instructor accounts, persistent student/instructor sessions, RBAC,
audit logging, and the instructor/admin control plane. No k3s, no `--privileged` — this
replaced Phase 2A's "identity" instance, which was a full Lab image booting an unused k3s
cluster just to serve these APIs. Design: `docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md` (split +
Postgres migration), `docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md` (sessions, accounts,
RBAC, audit). Operational reference: `api/README.md`.
- `api/` — the lightweight Platform API: students, cohorts, enrollments, curriculum, detailed
scenario attempt telemetry, progressive hints, curriculum completion rules, the exam +
capstone assessment engine, achievements, per-instructor accounts, persistent student/
instructor sessions, RBAC, audit logging, and the instructor/admin control plane. No k3s, no
`--privileged` — this replaced Phase 2A's "identity" instance, which was a full Lab image
booting an unused k3s cluster just to serve these APIs. Design:
`docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md` (split + Postgres migration),
`docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md` (sessions, accounts, RBAC, audit),
`docs/PHASE3B_LEARNING_AND_ASSESSMENT.md` (learning/assessment engine). Operational
reference: `api/README.md`.
- `gateway/` — the only student-facing path to a Lab (HTTP + WebSocket proxy, environment-aware
routing derived from a server-controlled session — not a client-suppliable header/param).
Design: `docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md` (routing), `docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md` (persistent sessions, CSRF).
routing derived from a server-controlled session — not a client-suppliable header/param — and,
as of Phase 3B, server-side gating of progressive hint reveal). Design:
`docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md` (routing), `docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md`
(persistent sessions, CSRF), `docs/PHASE3B_LEARNING_AND_ASSESSMENT.md` §3 (hint gating).
- `egress-proxy/` — shared allowlisting proxy every Lab's outbound traffic passes through.
- `docker-compose.dev.yml` — the full stack (Postgres, egress proxy, Provisioner, Platform API,
Gateway) on one machine. This is the Phase 1/2A/2B **local dev exception** — production is
@@ -54,16 +60,21 @@ further instructor accounts from the Instructors tab).
- `test/e2e-phase3a.js` — persistent sessions (including a real Platform API restart), instructor
auth (no enumeration, rate limiting), RBAC across owner/admin/instructor, cohort-scoped
instructor access, CSRF enforcement, password change, and audit logging.
- `test/e2e-phase3b.js` — attempt telemetry, progressive hint sequencing/persistence through a
Lab reset, curriculum stage/Capstone lock-unlock, the exam engine (random draw, server
timer, expiry, restart persistence), the Capstone workflow end to end, two-student assessment
isolation, and instructor learning analytics/RBAC.
All three e2e suites need `OWNER_EMAIL` / `OWNER_PASSWORD` set to a real bootstrapped instructor
All four e2e suites need `OWNER_EMAIL` / `OWNER_PASSWORD` set to a real bootstrapped instructor
account (see "Running the full stack locally" above) — the old `ADMIN_PASSWORD` mechanism is
gone. They're slow (real k3s boots) and need the full stack up. None of them substitute for the
manual real-Lab-image checks (node-pty, terminal WebSocket, `kubectl`, scenario validation) in
`docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md` §8 — those need a browser-equivalent client and
aren't automated here.
gone. They're slow (real k3s boots, `e2e-phase3b.js` also runs a real API restart) and need the
full stack up. None of them substitute for the manual real-Lab-image checks (node-pty, terminal
WebSocket, `kubectl`, scenario validation) in `docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md` §8 —
those need a browser-equivalent client and aren't automated here.
## Known limitations
See `provisioner/README.md` (Phase 1), `docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md` §9 (Phase
2A), and `docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md` (Phase 3A, supersedes Phase 2B's
identity limitations) for the full, honest lists.
2A), `docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md` (Phase 3A identity limitations), and
`docs/PHASE3B_LEARNING_AND_ASSESSMENT.md` §20 (Phase 3B learning/assessment limitations) for the
full, honest lists.
+17 -11
View File
@@ -1,9 +1,11 @@
# RootNode Academy — Platform API (Phase 3A)
# RootNode Academy — Platform API (Phase 3B)
Lightweight service: no k3s, no `--privileged`, no Lab terminal runtime. See
`docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md` for the original split/migration design and
`docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md` for the original split/migration design,
`docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md` for persistent sessions, per-instructor
accounts, RBAC, and audit logging — this file is the operational reference.
accounts, RBAC, and audit logging, and `docs/PHASE3B_LEARNING_AND_ASSESSMENT.md` for attempt
telemetry, progressive hints, curriculum rules, and the exam/capstone engine — this file is the
operational reference.
## Configuration reference
@@ -71,14 +73,18 @@ with instructor accounts, which are Phase 3A-native and start empty until you bo
## Tests
`platform/test/e2e-phase2a.js` (student-flow regression), `platform/test/e2e-phase2b.js` (the
classroom acceptance workflow), and `platform/test/e2e-phase3a.js` (persistent sessions
including a real restart, instructor auth, RBAC, cohort scoping, CSRF, audit log). All three
need the full stack up and `OWNER_EMAIL` / `OWNER_PASSWORD` set to a bootstrapped account — see
`platform/README.md`.
classroom acceptance workflow), `platform/test/e2e-phase3a.js` (persistent sessions including a
real restart, instructor auth, RBAC, cohort scoping, CSRF, audit log), and
`platform/test/e2e-phase3b.js` (attempt telemetry, progressive hints, curriculum/Capstone
unlock rules, the exam engine including a real restart, two-student assessment isolation,
instructor analytics). All four need the full stack up and `OWNER_EMAIL` / `OWNER_PASSWORD` set
to a bootstrapped account — see `platform/README.md`.
## Known limitations
See `docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md` "Known limitations" — sessions are
Postgres-backed (survive a restart) but there's no distributed revocation channel to the
Gateway's short-lived whoami cache, exam mode's live bookkeeping stays Lab-local, and OIDC/SSO
is a documented adapter interface, not a working integration yet.
See `docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md` "Known limitations" (sessions are
Postgres-backed and survive a restart, but there's no distributed revocation channel to the
Gateway's short-lived whoami cache; OIDC/SSO is a documented adapter interface, not a working
integration yet) and `docs/PHASE3B_LEARNING_AND_ASSESSMENT.md` §20 (no automatic
abandoned-attempt detection, needs-attention thresholds are fixed constants, `pool_spec`
authoring is raw JSON in the admin UI).
@@ -0,0 +1,55 @@
-- Evolves scenario_attempts (Phase 2B) from "one row per validation click" into a real attempt
-- lifecycle: one row per continuous working session on a scenario, with aggregated telemetry
-- counters, updated in place as the student works — not a new table (Phase 3B explicitly asks
-- to extend/refine rather than duplicate). See platform/api/src/attempts.js.
--
-- Backward compatible: every new column is nullable or has a DEFAULT, so Phase 2B's existing
-- rows (real historical data from testing) stay valid without being dropped or rewritten in
-- shape — the backfill UPDATE below just gives them sane values under the new semantics.
-- Phase 2B required `result` on every row (it was always written at completion time, atomically).
-- Under the new attempt lifecycle a row can exist mid-attempt (status='in_progress') with no
-- result yet — result is only ever set once the attempt reaches a terminal state.
ALTER TABLE scenario_attempts ALTER COLUMN result DROP NOT NULL;
-- hint_count (migration 005) was deliberately left NULL / undefaulted — Phase 2B's note was
-- "not measurable today." It's measurable now (this is the phase that builds real hint
-- tracking), so it needs a DEFAULT 0 for new rows to actually increment from, and existing NULL
-- rows backfilled to 0 (same "not measured" meaning either way, just now a real zero).
ALTER TABLE scenario_attempts ALTER COLUMN hint_count SET DEFAULT 0;
UPDATE scenario_attempts SET hint_count = 0 WHERE hint_count IS NULL;
ALTER TABLE scenario_attempts ADD COLUMN IF NOT EXISTS cohort_id TEXT;
ALTER TABLE scenario_attempts ADD COLUMN IF NOT EXISTS curriculum_stage TEXT;
ALTER TABLE scenario_attempts ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'completed';
ALTER TABLE scenario_attempts ADD COLUMN IF NOT EXISTS validation_attempt_count INTEGER NOT NULL DEFAULT 0;
ALTER TABLE scenario_attempts ADD COLUMN IF NOT EXISTS validation_pass_count INTEGER NOT NULL DEFAULT 0;
ALTER TABLE scenario_attempts ADD COLUMN IF NOT EXISTS validation_fail_count INTEGER NOT NULL DEFAULT 0;
ALTER TABLE scenario_attempts ADD COLUMN IF NOT EXISTS highest_hint_level INTEGER NOT NULL DEFAULT 0;
ALTER TABLE scenario_attempts ADD COLUMN IF NOT EXISTS duration_seconds INTEGER;
ALTER TABLE scenario_attempts ADD COLUMN IF NOT EXISTS assessment_session_id UUID;
DO $$ BEGIN
ALTER TABLE scenario_attempts ADD CONSTRAINT scenario_attempts_status_check
CHECK (status IN ('in_progress', 'completed', 'abandoned'));
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
-- Backfill: Phase 2B rows were written atomically at completion time (one INSERT per validate
-- call, already carrying completed_at + result) — under the new semantics that's a completed
-- attempt with exactly one validation performed.
UPDATE scenario_attempts SET
status = 'completed',
validation_attempt_count = 1,
validation_pass_count = CASE WHEN result = 'passed' THEN 1 ELSE 0 END,
validation_fail_count = CASE WHEN result = 'failed' THEN 1 ELSE 0 END
WHERE validation_attempt_count = 0 AND completed_at IS NOT NULL;
-- "Find the open attempt for this student+scenario+environment" is the hot lookup path (every
-- hint reveal and validate call needs it) — environment-scoped so a Lab recreate doesn't
-- silently resume an attempt tied to a destroyed environment.
CREATE INDEX IF NOT EXISTS idx_attempts_open_lookup
ON scenario_attempts(student_id, scenario_id, environment_id, status);
CREATE INDEX IF NOT EXISTS idx_attempts_cohort ON scenario_attempts(cohort_id);
CREATE INDEX IF NOT EXISTS idx_attempts_assessment_session ON scenario_attempts(assessment_session_id);
@@ -0,0 +1,98 @@
-- Assessment engine: definitions (exam/capstone templates), cohort assignment, live sessions,
-- and the per-session scenario draw. exam_sessions (migration 006) is left untouched — it was
-- already scoped in Phase 2B as historical-import-only from the old Lab-local SQLite exam
-- bookkeeping, never the live mechanism. These tables ARE the live mechanism; duplicating
-- exam_sessions' purpose would mean building the real thing twice, so this doesn't reuse it.
--
-- Definitions are lightly versioned (a plain incrementing integer, not full history) so a
-- later edit to pool_spec/duration/hint_policy never changes what an already-started session is
-- graded against — every session snapshots the fields that matter at draw time.
CREATE TABLE IF NOT EXISTS assessment_definitions (
assessment_definition_id TEXT PRIMARY KEY, -- slug, e.g. 'rootnode-capstone'
type TEXT NOT NULL CHECK (type IN ('exam', 'capstone')),
title TEXT NOT NULL,
description TEXT,
duration_minutes INTEGER NOT NULL,
hint_policy TEXT NOT NULL DEFAULT 'disabled' CHECK (hint_policy IN ('free', 'disabled', 'flagged')),
pool_spec JSONB NOT NULL, -- {"required": [scenario_id,...], "random": [{"count":n,"from":[scenario_id,...]},...]}
completion_rule JSONB NOT NULL DEFAULT '{"type":"all_scenarios_passed"}',
unlock_rule JSONB, -- capstone only: {"type":"stages_complete","stage_ids":[...]}
version INTEGER NOT NULL DEFAULT 1,
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'inactive')),
created_by UUID REFERENCES instructors(instructor_id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS assessment_cohort_assignments (
assessment_definition_id TEXT NOT NULL REFERENCES assessment_definitions(assessment_definition_id),
cohort_id TEXT NOT NULL REFERENCES cohorts(cohort_id),
assigned_by UUID REFERENCES instructors(instructor_id),
assigned_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (assessment_definition_id, cohort_id)
);
CREATE TABLE IF NOT EXISTS assessment_sessions (
assessment_session_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
assessment_definition_id TEXT NOT NULL REFERENCES assessment_definitions(assessment_definition_id),
definition_version INTEGER NOT NULL, -- snapshot: which version of the definition was drawn against
student_id TEXT NOT NULL REFERENCES students(student_id),
cohort_id TEXT,
type TEXT NOT NULL, -- snapshot
title TEXT NOT NULL, -- snapshot
duration_minutes INTEGER NOT NULL, -- snapshot
hint_policy TEXT NOT NULL, -- snapshot
status TEXT NOT NULL DEFAULT 'not_started'
CHECK (status IN ('not_started', 'in_progress', 'completed', 'expired', 'cancelled')),
started_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ, -- server-computed = started_at + duration_minutes; authoritative, never client-set
submitted_at TIMESTAMPTZ,
random_seed TEXT, -- recorded for audit/reproducibility, not exposed to the student before the draw
result JSONB, -- {"scenarios_total":n,"scenarios_passed":n,"passed":bool}
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS assessment_session_scenarios (
id BIGSERIAL PRIMARY KEY,
assessment_session_id UUID NOT NULL REFERENCES assessment_sessions(assessment_session_id),
scenario_id TEXT NOT NULL,
position INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'in_progress', 'completed', 'failed')),
attempt_id BIGINT REFERENCES scenario_attempts(attempt_id),
UNIQUE (assessment_session_id, scenario_id)
);
DO $$ BEGIN
ALTER TABLE scenario_attempts ADD CONSTRAINT scenario_attempts_assessment_session_fk
FOREIGN KEY (assessment_session_id) REFERENCES assessment_sessions(assessment_session_id);
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
CREATE INDEX IF NOT EXISTS idx_assessment_sessions_student ON assessment_sessions(student_id, status);
CREATE INDEX IF NOT EXISTS idx_assessment_sessions_definition ON assessment_sessions(assessment_definition_id);
CREATE INDEX IF NOT EXISTS idx_assessment_session_scenarios_session ON assessment_session_scenarios(assessment_session_id);
-- Seed defaults — a working RootNode Capstone and one example exam ship out of the box, per the
-- Phase 3B spec ("ship with a sensible RootNode default"). Scenario ids are a snapshot of
-- scenarios/rootnode-path.json's stage lists as of this migration; edit the definition via the
-- admin API if curriculum content changes later, same as any other admin-owned config.
INSERT INTO assessment_definitions (assessment_definition_id, type, title, description, duration_minutes, hint_policy, pool_spec, completion_rule, unlock_rule)
VALUES (
'rootnode-capstone', 'capstone', 'RootNode Capstone',
'Combines skills from every curriculum stage into one operational assessment, weighted toward RootNode Real Ops incident scenarios. Evaluates operational ability, not memorized kubectl syntax.',
90, 'flagged',
'{"required": ["rootnode-runaway-monitor","broken-deployment","rootnode-probe-port-mismatch","rootnode-enforce-limits"], "random": [{"count":1,"from":["deploy-nginx","scale-deployment","rolling-update-task","liveness-probe-task","edit-deployment-basics"]},{"count":1,"from":["resource-limits-task","env-vars-configmap","secrets-basics"]},{"count":1,"from":["extract-logs-basics","kubectl-cp-basics","exec-command-basics","node-cordon-basics"]}]}',
'{"type":"all_scenarios_passed"}',
'{"type":"stages_complete","stage_ids":["foundation","workloads","networking","configuration","troubleshooting","real-ops"]}'
) ON CONFLICT (assessment_definition_id) DO NOTHING;
INSERT INTO assessment_definitions (assessment_definition_id, type, title, description, duration_minutes, hint_policy, pool_spec, completion_rule)
VALUES (
'rootnode-quick-assessment', 'exam', 'RootNode Kubernetes Assessment',
'A timed, randomized skills check drawn from across the curriculum. Example exam definition — instructors can create their own from the admin portal.',
60, 'disabled',
'{"required": [], "random": [{"count":2,"from":["deploy-nginx","scale-deployment","rolling-update-task","liveness-probe-task","daemonset-mcq"]},{"count":1,"from":["services-mcq","expose-service-basics"]},{"count":1,"from":["extract-logs-basics","kubectl-cp-basics","exec-command-basics","node-cordon-basics"]},{"count":1,"from":["rootnode-runaway-monitor","broken-deployment","rootnode-probe-port-mismatch","rootnode-enforce-limits"]}]}',
'{"type":"all_scenarios_passed"}'
) ON CONFLICT (assessment_definition_id) DO NOTHING;
@@ -0,0 +1,19 @@
-- Persistent completion records — stage/curriculum/assessment/capstone. Not a certificate
-- (Phase 4/5 territory, see docs/PHASE3B_LEARNING_AND_ASSESSMENT.md): just the durable fact
-- that something was achieved, with enough context to render a certificate from later without
-- re-deriving it from raw attempt history.
CREATE TABLE IF NOT EXISTS achievements (
achievement_id BIGSERIAL PRIMARY KEY,
student_id TEXT NOT NULL REFERENCES students(student_id),
achievement_type TEXT NOT NULL CHECK (achievement_type IN
('stage_completed', 'curriculum_completed', 'assessment_completed', 'capstone_completed')),
curriculum_path_id TEXT,
curriculum_version INTEGER,
stage_id TEXT,
assessment_session_id UUID REFERENCES assessment_sessions(assessment_session_id),
achieved_at TIMESTAMPTZ NOT NULL DEFAULT now(),
metadata JSONB
);
CREATE INDEX IF NOT EXISTS idx_achievements_student ON achievements(student_id, achievement_type);
+1281
View File
File diff suppressed because it is too large Load Diff
+60
View File
@@ -0,0 +1,60 @@
// Persistent completion records. recordAchievement is idempotent (a WHERE NOT EXISTS guard,
// not a unique-index/ON CONFLICT dance — simple and correct at this write volume) so calling it
// again for something already recorded is a safe no-op, not a duplicate row.
async function recordAchievement(pool, { studentId, type, curriculumPathId, curriculumVersion, stageId, assessmentSessionId, metadata }) {
const { rows } = await pool.query(
`INSERT INTO achievements (student_id, achievement_type, curriculum_path_id, curriculum_version, stage_id, assessment_session_id, metadata)
SELECT $1, $2, $3, $4, $5, $6, $7
WHERE NOT EXISTS (
SELECT 1 FROM achievements
WHERE student_id = $1 AND achievement_type = $2
AND stage_id IS NOT DISTINCT FROM $5
AND assessment_session_id IS NOT DISTINCT FROM $6
)
RETURNING *`,
[studentId, type, curriculumPathId || null, curriculumVersion || null, stageId || null, assessmentSessionId || null, metadata ? JSON.stringify(metadata) : null]
);
return rows[0] || null; // null means it already existed — not an error
}
// Called after a progress update — checks whether this newly-completed scenario just finished
// a stage (and, transitively, the whole curriculum), recording achievements as needed. Cheap:
// only runs the curriculum-state computation the caller already has on hand.
async function checkStageAndCurriculumAchievements(pool, { studentId, path, curriculumState }) {
for (const stage of curriculumState.stages) {
if (stage.complete) {
await recordAchievement(pool, {
studentId, type: 'stage_completed', curriculumPathId: path.id, curriculumVersion: path.version,
stageId: stage.id,
});
}
}
const allContentStagesComplete = curriculumState.stages.every((s) => s.complete);
if (allContentStagesComplete) {
await recordAchievement(pool, {
studentId, type: 'curriculum_completed', curriculumPathId: path.id, curriculumVersion: path.version,
});
}
}
// Called when assessments.recordScenarioOutcome/submitSession finalizes a session — records
// 'capstone_completed' or 'assessment_completed' depending on the session's type. Only fires on
// an actual pass (defensive check — the only completion_rule implemented today is
// all_scenarios_passed, which already guarantees this, but a future threshold-based rule
// shouldn't silently start granting achievements for a partial result).
async function recordAssessmentAchievement(pool, session) {
if (!session || !session.result?.passed) return null;
const type = session.type === 'capstone' ? 'capstone_completed' : 'assessment_completed';
return recordAchievement(pool, {
studentId: session.student_id, type, assessmentSessionId: session.assessment_session_id,
metadata: { assessment_definition_id: session.assessment_definition_id, title: session.title },
});
}
async function listAchievements(pool, studentId) {
const { rows } = await pool.query(`SELECT * FROM achievements WHERE student_id = $1 ORDER BY achieved_at DESC`, [studentId]);
return rows;
}
module.exports = { recordAchievement, checkStageAndCurriculumAchievements, recordAssessmentAchievement, listAchievements };
+264
View File
@@ -0,0 +1,264 @@
// Instructor/Admin learning API — cohort/student/scenario analytics, "needs attention," and
// assessment (exam + capstone) definition/session management. Same RBAC shape as
// admin-routes.js: owner/admin get broad or global access, a plain 'instructor' is scoped to
// their assigned cohorts (see rbac.js). Assessment/capstone DEFINITION authoring (create/edit/
// activate) stays admin/owner-only — assigning an already-approved assessment to one of their
// own cohorts, and viewing/canceling sessions within their scope, is what a plain instructor
// can do, per the Phase 3B RBAC matrix.
const { computeCurriculumState } = require('./curriculum-rules');
const NEEDS_ATTENTION_FAIL_THRESHOLD = 3; // repeated validation failures on one scenario
const 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;
const { requireAdminOrOwner, requireAnyInstructor, requireCohortAccess, requireStudentCohortAccess } = rbac;
function wrap(fn) {
return (req, res, next) => {
try { const r = fn(req, res, next); if (r && r.catch) r.catch(next); } catch (e) { next(e); }
};
}
const admin = {
get: (p, ...mw) => app.get(p, instructorSession, ...mw.map(wrap)),
post: (p, ...mw) => app.post(p, instructorSession, ...mw.map(wrap)),
patch: (p, ...mw) => app.patch(p, instructorSession, ...mw.map(wrap)),
};
// ── Cohort learning summary ──────────────────────────────────────────────────────────────
admin.get('/api/admin/cohorts/:id/learning', requireAnyInstructor, requireCohortAccess('id'), async (req, res) => {
const pool = getPool();
const path = curriculum.loadCurriculumPath();
const { rows: students } = await pool.query(
`SELECT s.student_id, s.display_name, s.last_active_at FROM cohort_enrollments e
JOIN students s ON s.student_id = e.student_id
WHERE e.cohort_id = $1 AND e.enrollment_status = 'enrolled'`,
[req.params.id]
);
const stageTotals = path ? path.stages.filter((s) => s.type !== 'capstone').map((s) => ({ id: s.id, name: s.name, completedBy: 0 })) : [];
let overallPct = 0, activeLearners = 0;
const staleCutoff = Date.now() - NEEDS_ATTENTION_STALE_DAYS * 86400000;
for (const s of students) {
const progress = await loadProgress(pool, s.student_id);
const state = path ? computeCurriculumState(path, progress) : { stages: [], percent_complete: 0 };
overallPct += state.percent_complete;
if (s.last_active_at && new Date(s.last_active_at).getTime() > staleCutoff) activeLearners++;
state.stages.forEach((st, i) => { if (st.complete) stageTotals[i].completedBy++; });
}
res.json({
student_count: students.length,
active_learners: activeLearners,
overall_completion_pct: students.length ? Math.round(overallPct / students.length) : 0,
stages: stageTotals.map((s) => ({ ...s, pct: students.length ? Math.round((s.completedBy / students.length) * 100) : 0 })),
});
});
// ── Student learning detail ──────────────────────────────────────────────────────────────
admin.get('/api/admin/students/:id/learning', requireAnyInstructor, requireStudentCohortAccess('id'), async (req, res) => {
const pool = getPool();
const path = curriculum.loadCurriculumPath();
const progress = await loadProgress(pool, req.params.id);
const capstoneAchievement = (await pool.query(
`SELECT * FROM achievements WHERE student_id = $1 AND achievement_type = 'capstone_completed' LIMIT 1`, [req.params.id]
)).rows[0];
const state = path ? computeCurriculumState(path, progress, capstoneAchievement ? 'completed' : undefined) : null;
const { rows: attempts } = await pool.query(
`SELECT scenario_id, status, result, validation_attempt_count, validation_pass_count, validation_fail_count,
hint_count, highest_hint_level, duration_seconds, started_at, completed_at
FROM scenario_attempts WHERE student_id = $1 ORDER BY started_at DESC NULLS LAST LIMIT 100`,
[req.params.id]
);
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`,
[req.params.id]
);
res.json({
curriculum: state, attempts, assessment_sessions: assessmentSessions,
achievements: (await pool.query(`SELECT * FROM achievements WHERE student_id = $1 ORDER BY achieved_at DESC`, [req.params.id])).rows,
});
});
// ── Scenario analytics ───────────────────────────────────────────────────────────────────
admin.get('/api/admin/scenario-analytics', requireAnyInstructor, async (req, res) => {
const pool = getPool();
const cohortId = req.query.cohort_id || null;
if (req.instructor.role === 'instructor') {
if (!cohortId) return res.status(400).json({ error: 'cohort_id is required for the instructor role' });
const { rowCount } = await pool.query(`SELECT 1 FROM cohort_instructors WHERE cohort_id = $1 AND instructor_id = $2`, [cohortId, req.instructor.instructor_id]);
if (!rowCount) return res.status(403).json({ error: 'forbidden' });
}
const { rows } = await pool.query(
`SELECT scenario_id,
count(*) FILTER (WHERE status = 'completed')::int AS completions,
count(DISTINCT student_id)::int AS distinct_students,
avg(validation_attempt_count)::numeric(10,1) AS avg_attempts,
sum(validation_fail_count)::int AS total_failures,
sum(hint_count)::int AS total_hints_used,
avg(duration_seconds) FILTER (WHERE duration_seconds IS NOT NULL)::int AS avg_duration_seconds
FROM scenario_attempts
WHERE ($1::text IS NULL OR cohort_id = $1)
GROUP BY scenario_id ORDER BY completions DESC`,
[cohortId]
);
res.json(rows);
});
// ── Needs attention ───────────────────────────────────────────────────────────────────────
admin.get('/api/admin/cohorts/:id/needs-attention', requireAnyInstructor, requireCohortAccess('id'), async (req, res) => {
const pool = getPool();
const staleCutoff = new Date(Date.now() - NEEDS_ATTENTION_STALE_DAYS * 86400000);
const { rows: students } = await pool.query(
`SELECT s.student_id, s.display_name, s.last_active_at FROM cohort_enrollments e
JOIN students s ON s.student_id = e.student_id WHERE e.cohort_id = $1 AND e.enrollment_status = 'enrolled'`,
[req.params.id]
);
const signals = [];
for (const s of students) {
const { rows: stuck } = await pool.query(
`SELECT scenario_id, validation_fail_count, highest_hint_level FROM scenario_attempts
WHERE student_id = $1 AND status = 'in_progress' AND validation_fail_count >= $2
ORDER BY validation_fail_count DESC LIMIT 1`,
[s.student_id, NEEDS_ATTENTION_FAIL_THRESHOLD]
);
if (stuck[0]) {
signals.push({ student_id: s.student_id, display_name: s.display_name, reason: 'repeated_attempts',
label: 'Repeated Attempts', scenario_id: stuck[0].scenario_id, detail: `${stuck[0].validation_fail_count} failed validations` });
}
const { rows: maxedHints } = await pool.query(
`SELECT sa.scenario_id, sa.highest_hint_level FROM scenario_attempts sa
WHERE sa.student_id = $1 AND sa.status = 'in_progress' AND sa.highest_hint_level > 0 LIMIT 1`,
[s.student_id]
);
if (maxedHints[0]) {
signals.push({ student_id: s.student_id, display_name: s.display_name, reason: 'used_hints',
label: 'May Need Help', scenario_id: maxedHints[0].scenario_id, detail: `hint level ${maxedHints[0].highest_hint_level} used, still in progress` });
}
if (!s.last_active_at || new Date(s.last_active_at) < staleCutoff) {
signals.push({ student_id: s.student_id, display_name: s.display_name, reason: 'inactive',
label: 'Needs Attention', detail: s.last_active_at ? `inactive since ${s.last_active_at}` : 'never active' });
}
const { rows: failedAssessment } = await pool.query(
`SELECT assessment_session_id, title FROM assessment_sessions WHERE student_id = $1 AND status = 'expired' ORDER BY created_at DESC LIMIT 1`,
[s.student_id]
);
if (failedAssessment[0]) {
signals.push({ student_id: s.student_id, display_name: s.display_name, reason: 'incomplete_assessment',
label: 'Needs Attention', detail: `"${failedAssessment[0].title}" expired without completion` });
}
}
res.json(signals);
});
// ── Assessment definitions ───────────────────────────────────────────────────────────────
admin.get('/api/admin/assessments', requireAnyInstructor, async (req, res) => {
const { rows } = await getPool().query(`SELECT * FROM assessment_definitions ORDER BY type, title`);
res.json(rows);
});
admin.post('/api/admin/assessments', requireAdminOrOwner, async (req, res) => {
const { id, type, title, description, duration_minutes, hint_policy, pool_spec, completion_rule, unlock_rule } = req.body || {};
if (!id || !type || !title || !duration_minutes || !pool_spec) {
return res.status(400).json({ error: 'id, type, title, duration_minutes, and pool_spec are required' });
}
if (!['exam', 'capstone'].includes(type)) return res.status(400).json({ error: 'invalid type' });
const pool = getPool();
try {
await pool.query(
`INSERT INTO assessment_definitions (assessment_definition_id, type, title, description, duration_minutes, hint_policy, pool_spec, completion_rule, unlock_rule, created_by)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`,
[id, type, title, description || null, duration_minutes, hint_policy || 'disabled', JSON.stringify(pool_spec),
JSON.stringify(completion_rule || { type: 'all_scenarios_passed' }), unlock_rule ? JSON.stringify(unlock_rule) : null, req.instructor.instructor_id]
);
} catch (e) {
if (e.code === '23505') return res.status(409).json({ error: 'id already exists' });
throw e;
}
await recordAudit(pool, { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'assessment_create', resourceType: 'assessment_definition', resourceId: id, result: 'success', sourceIp: req.ip });
res.status(201).json({ id });
});
admin.patch('/api/admin/assessments/:id', requireAdminOrOwner, async (req, res) => {
const { title, description, duration_minutes, hint_policy, pool_spec, completion_rule, unlock_rule, status } = req.body || {};
const pool = getPool();
const { rows } = await pool.query(`SELECT * FROM assessment_definitions WHERE assessment_definition_id = $1`, [req.params.id]);
const current = rows[0];
if (!current) return res.status(404).json({ error: 'not found' });
// Anything that would change what an in-progress session is graded against bumps the
// version — already-started sessions keep their own snapshot, so this never changes a
// result retroactively.
const semanticChange = [
duration_minutes !== undefined && duration_minutes !== current.duration_minutes,
hint_policy !== undefined && hint_policy !== current.hint_policy,
pool_spec !== undefined && JSON.stringify(pool_spec) !== JSON.stringify(current.pool_spec),
completion_rule !== undefined && JSON.stringify(completion_rule) !== JSON.stringify(current.completion_rule),
].some(Boolean);
const fields = ['updated_at = now()'], values = [];
const set = (col, val) => { values.push(val); fields.push(`${col} = $${values.length}`); };
if (title !== undefined) set('title', title);
if (description !== undefined) set('description', description);
if (duration_minutes !== undefined) set('duration_minutes', duration_minutes);
if (hint_policy !== undefined) set('hint_policy', hint_policy);
if (pool_spec !== undefined) set('pool_spec', JSON.stringify(pool_spec));
if (completion_rule !== undefined) set('completion_rule', JSON.stringify(completion_rule));
if (unlock_rule !== undefined) set('unlock_rule', JSON.stringify(unlock_rule));
if (status !== undefined && ['active', 'inactive'].includes(status)) set('status', status);
if (semanticChange) { values.push(current.version + 1); fields.push(`version = $${values.length}`); }
values.push(req.params.id);
await pool.query(`UPDATE assessment_definitions SET ${fields.join(', ')} WHERE assessment_definition_id = $${values.length}`, values);
await recordAudit(pool, { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'assessment_update', resourceType: 'assessment_definition', resourceId: req.params.id, result: 'success', sourceIp: req.ip, metadata: { semanticChange } });
res.json({ ok: true, version_bumped: semanticChange });
});
admin.post('/api/admin/assessments/:id/assign', requireAnyInstructor, async (req, res) => {
const { cohort_id } = req.body || {};
if (!cohort_id) return res.status(400).json({ error: 'cohort_id required' });
if (req.instructor.role === 'instructor') {
const { rowCount } = await getPool().query(`SELECT 1 FROM cohort_instructors WHERE cohort_id = $1 AND instructor_id = $2`, [cohort_id, req.instructor.instructor_id]);
if (!rowCount) return res.status(403).json({ error: 'forbidden' });
}
const pool = getPool();
await pool.query(
`INSERT INTO assessment_cohort_assignments (assessment_definition_id, cohort_id, assigned_by) VALUES ($1,$2,$3)
ON CONFLICT (assessment_definition_id, cohort_id) DO NOTHING`,
[req.params.id, cohort_id, req.instructor.instructor_id]
);
await recordAudit(pool, { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'assessment_assign', resourceType: 'assessment_definition', resourceId: req.params.id, result: 'success', sourceIp: req.ip, metadata: { cohort_id } });
res.json({ ok: true });
});
admin.get('/api/admin/assessments/:id/sessions', requireAnyInstructor, async (req, res) => {
const pool = getPool();
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);
});
admin.post('/api/admin/assessment-sessions/:sessionId/cancel', requireAnyInstructor, async (req, res) => {
const pool = getPool();
const { rows } = await pool.query(`SELECT * FROM assessment_sessions WHERE assessment_session_id = $1`, [req.params.sessionId]);
const session = rows[0];
if (!session) return res.status(404).json({ error: 'not found' });
if (req.instructor.role === 'instructor') {
const { rowCount } = await pool.query(`SELECT 1 FROM cohort_instructors WHERE cohort_id = $1 AND instructor_id = $2`, [session.cohort_id, req.instructor.instructor_id]);
if (!rowCount) return res.status(403).json({ error: 'forbidden' });
}
await pool.query(`UPDATE assessment_sessions SET status = 'cancelled', submitted_at = COALESCE(submitted_at, now()) WHERE assessment_session_id = $1 AND status IN ('not_started','in_progress')`, [req.params.sessionId]);
await recordAudit(pool, { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'assessment_session_cancel', resourceType: 'assessment_session', resourceId: req.params.sessionId, result: 'success', sourceIp: req.ip });
res.json({ ok: true });
});
};
+176
View File
@@ -0,0 +1,176 @@
// Assessment engine — exam and capstone definitions, cohort assignment, and live sessions.
// The timer and the drawn scenario set are BOTH server-authoritative: expires_at is computed
// once at session start and never recomputed from a client-supplied value, and the drawn
// scenario_ids live only in Postgres (assessment_session_scenarios) — a student can see them
// (they need to know what to work on) but can't set or replace them. See
// docs/PHASE3B_LEARNING_AND_ASSESSMENT.md §19 for the full integrity story.
const crypto = require('crypto');
// Tiny deterministic PRNG (mulberry32) — enough for a reproducible/audit-able draw without a
// new dependency. Seeded from a string via a cheap hash; the seed itself is recorded on the
// session for audit, not exposed to the student before the draw happens.
function mulberry32(seed) {
let a = seed >>> 0;
return function () {
a |= 0; a = (a + 0x6D2B79F5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function seedToInt(seed) {
let h = 0;
for (let i = 0; i < seed.length; i++) { h = (Math.imul(31, h) + seed.charCodeAt(i)) | 0; }
return h;
}
function shuffle(arr, rng) {
const a = [...arr];
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(rng() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
// Draws a scenario set from a pool_spec: every id in `required` is included, plus `count`
// randomly-chosen ids from each `random` group's `from` list, all shuffled together into the
// student's presentation order.
function drawScenarios(poolSpec, seed) {
const actualSeed = seed || crypto.randomBytes(8).toString('hex');
const rng = mulberry32(seedToInt(actualSeed));
const required = poolSpec.required || [];
const randomPicks = (poolSpec.random || []).flatMap((group) => shuffle(group.from, rng).slice(0, group.count));
return { scenario_ids: shuffle([...required, ...randomPicks], rng), seed: actualSeed };
}
async function getActiveSessionForStudent(pool, studentId) {
const { rows } = await pool.query(
`SELECT * FROM assessment_sessions WHERE student_id = $1 AND status IN ('not_started', 'in_progress')
ORDER BY created_at DESC LIMIT 1`,
[studentId]
);
const session = rows[0];
if (!session) return null;
return lazyExpire(pool, session);
}
// Lazy expiry — checked whenever a session is read or written, rather than a background cron
// (no multi-host scheduler in this phase, see the Phase 3B non-goals list). A session past its
// server-computed expires_at is transitioned to 'expired' on the next touch, whoever that is.
async function lazyExpire(pool, session) {
if (session.status === 'in_progress' && session.expires_at && new Date(session.expires_at) <= new Date()) {
const { rows } = await pool.query(
`UPDATE assessment_sessions SET status = 'expired', submitted_at = COALESCE(submitted_at, now())
WHERE assessment_session_id = $1 AND status = 'in_progress' RETURNING *`,
[session.assessment_session_id]
);
return rows[0] || session;
}
return session;
}
async function getSessionScenarios(pool, sessionId) {
const { rows } = await pool.query(
`SELECT * FROM assessment_session_scenarios WHERE assessment_session_id = $1 ORDER BY position`, [sessionId]
);
return rows;
}
// Starts a new session for a student against a definition — refuses if they already have a
// live (not_started/in_progress) session for ANY definition, exam or capstone (one assessment
// at a time; keeps the "which Lab scenario am I working on for which purpose" question
// unambiguous for both the student and the hint-policy/attempt-linking logic).
async function startSession(pool, { definition, studentId, cohortId }) {
const active = await getActiveSessionForStudent(pool, studentId);
if (active) { const err = new Error('An assessment is already in progress.'); err.code = 'ALREADY_ACTIVE'; throw err; }
const { scenario_ids, seed } = drawScenarios(definition.pool_spec, null);
const startedAt = new Date();
const expiresAt = new Date(startedAt.getTime() + definition.duration_minutes * 60 * 1000);
const client = await pool.connect();
try {
await client.query('BEGIN');
const { rows } = await client.query(
`INSERT INTO assessment_sessions
(assessment_definition_id, definition_version, student_id, cohort_id, type, title, duration_minutes, hint_policy,
status, started_at, expires_at, random_seed)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'in_progress',$9,$10,$11) RETURNING *`,
[definition.assessment_definition_id, definition.version, studentId, cohortId || null, definition.type,
definition.title, definition.duration_minutes, definition.hint_policy, startedAt, expiresAt, seed]
);
const session = rows[0];
for (let i = 0; i < scenario_ids.length; i++) {
await client.query(
`INSERT INTO assessment_session_scenarios (assessment_session_id, scenario_id, position) VALUES ($1,$2,$3)`,
[session.assessment_session_id, scenario_ids[i], i]
);
}
await client.query('COMMIT');
return session;
} catch (e) {
await client.query('ROLLBACK');
throw e;
} finally {
client.release();
}
}
// Finds the live session (if any) that has THIS scenario_id in its draw for THIS student — the
// server-side signal that a validate/hint call is happening "inside" an assessment, derived
// entirely from what the server itself stored at draw time. A client asserting "I'm in
// assessment mode" is never trusted; this lookup is the only source of truth.
async function findActiveAssessmentContextForScenario(pool, studentId, scenarioId) {
const session = await getActiveSessionForStudent(pool, studentId);
if (!session || session.status !== 'in_progress') return null;
const { rows } = await pool.query(
`SELECT * FROM assessment_session_scenarios WHERE assessment_session_id = $1 AND scenario_id = $2`,
[session.assessment_session_id, scenarioId]
);
if (!rows[0]) return null;
return { session, sessionScenario: rows[0] };
}
// Updates the per-scenario row after a scenario_attempts completion, and finalizes the whole
// session once every drawn scenario is in a terminal state. Returns the finalized session row
// if this call was the one that completed it, otherwise null — the caller uses that to decide
// whether to record an achievement.
async function recordScenarioOutcome(pool, sessionId, scenarioId, attemptId, passed) {
await pool.query(
`UPDATE assessment_session_scenarios SET status = $3, attempt_id = $4
WHERE assessment_session_id = $1 AND scenario_id = $2`,
[sessionId, scenarioId, passed ? 'completed' : 'in_progress', attemptId]
);
if (!passed) return null; // still workable — session stays open until this scenario passes or time runs out
const scenarios = await getSessionScenarios(pool, sessionId);
const allDone = scenarios.every((s) => s.status === 'completed');
if (!allDone) return null;
const total = scenarios.length;
const passedCount = scenarios.filter((s) => s.status === 'completed').length;
const { rows } = await pool.query(
`UPDATE assessment_sessions SET status = 'completed', submitted_at = now(),
result = $2 WHERE assessment_session_id = $1 AND status = 'in_progress' RETURNING *`,
[sessionId, JSON.stringify({ scenarios_total: total, scenarios_passed: passedCount, passed: passedCount === total })]
);
return rows[0] || null;
}
async function submitSession(pool, sessionId) {
const scenarios = await getSessionScenarios(pool, sessionId);
const total = scenarios.length;
const passedCount = scenarios.filter((s) => s.status === 'completed').length;
const { rows } = await pool.query(
`UPDATE assessment_sessions SET status = 'completed', submitted_at = now(), result = $2
WHERE assessment_session_id = $1 AND status = 'in_progress' RETURNING *`,
[sessionId, JSON.stringify({ scenarios_total: total, scenarios_passed: passedCount, passed: passedCount === total })]
);
return rows[0] || null;
}
module.exports = {
drawScenarios, getActiveSessionForStudent, lazyExpire, getSessionScenarios, startSession,
findActiveAssessmentContextForScenario, recordScenarioOutcome, submitSession,
};
+83
View File
@@ -0,0 +1,83 @@
// Attempt lifecycle — the Phase 3B core. One scenario_attempts row per continuous working
// session on a scenario (not per validation click, see migrations/012). Every mutation here
// updates that one row in place; nothing is ever inserted twice for the same open attempt.
//
// Deliberately does NOT try to detect "abandoned" attempts automatically (e.g. on Lab destroy)
// — that would need a reaper-style background job wired to Provisioner environment_events,
// which is more machinery than the instructional-visibility goal here needs. An attempt just
// stays 'in_progress' if the student never returns to finish it; instructors can already see
// "in_progress, N failed validations" as a real signal. Documented as a known limitation.
async function getOrCreateOpenAttempt(pool, { studentId, scenarioId, environmentId, cohortId, curriculumStage, assessmentSessionId }) {
const { rows: existing } = await pool.query(
`SELECT * FROM scenario_attempts
WHERE student_id = $1 AND scenario_id = $2 AND environment_id = $3 AND status = 'in_progress'
ORDER BY started_at DESC LIMIT 1`,
[studentId, scenarioId, environmentId]
);
if (existing[0]) return existing[0];
const { rows } = await pool.query(
`INSERT INTO scenario_attempts
(student_id, scenario_id, environment_id, cohort_id, curriculum_stage, started_at, status, assessment_session_id)
VALUES ($1, $2, $3, $4, $5, now(), 'in_progress', $6)
RETURNING *`,
[studentId, scenarioId, environmentId, cohortId || null, curriculumStage || null, assessmentSessionId || null]
);
return rows[0];
}
// Records one validation outcome against the currently open attempt (creating one first if
// none exists — a student can hit Validate without an explicit "start" call ever having fired,
// e.g. if the frontend missed it; this stays correct either way). Closes the attempt on a pass.
async function recordValidation(pool, ctx, passed) {
const attempt = await getOrCreateOpenAttempt(pool, ctx);
const now = new Date();
if (passed) {
const durationSeconds = attempt.started_at ? Math.max(0, Math.round((now - new Date(attempt.started_at)) / 1000)) : null;
const { rows } = await pool.query(
`UPDATE scenario_attempts SET
validation_attempt_count = validation_attempt_count + 1,
validation_pass_count = validation_pass_count + 1,
status = 'completed', result = 'passed', completed_at = now(), duration_seconds = $2
WHERE attempt_id = $1 RETURNING *`,
[attempt.attempt_id, durationSeconds]
);
return rows[0];
}
const { rows } = await pool.query(
`UPDATE scenario_attempts SET
validation_attempt_count = validation_attempt_count + 1,
validation_fail_count = validation_fail_count + 1
WHERE attempt_id = $1 RETURNING *`,
[attempt.attempt_id]
);
return rows[0];
}
// Reveals the next hint, sequentially — level N can only be revealed once level N-1 has been.
// Returns null if the requested reveal is out of order (caller treats that as a 409/400) or if
// there are no more hints left.
async function recordHintReveal(pool, ctx, totalHints) {
const attempt = await getOrCreateOpenAttempt(pool, ctx);
const nextLevel = attempt.highest_hint_level + 1;
if (nextLevel > totalHints) return { ok: false, reason: 'no_more_hints', attempt };
const { rows } = await pool.query(
`UPDATE scenario_attempts SET hint_count = hint_count + 1, highest_hint_level = $2
WHERE attempt_id = $1 RETURNING *`,
[attempt.attempt_id, nextLevel]
);
return { ok: true, level: nextLevel, attempt: rows[0] };
}
async function getAttemptSummary(pool, { studentId, scenarioId, environmentId }) {
const { rows } = await pool.query(
`SELECT * FROM scenario_attempts
WHERE student_id = $1 AND scenario_id = $2 AND ($3::uuid IS NULL OR environment_id = $3)
ORDER BY started_at DESC LIMIT 1`,
[studentId, scenarioId, environmentId || null]
);
return rows[0] || null;
}
module.exports = { getOrCreateOpenAttempt, recordValidation, recordHintReveal, getAttemptSummary };
+83
View File
@@ -0,0 +1,83 @@
// Curriculum progression rules — reads scenarios/rootnode-path.json's per-stage
// completion_rule (and the capstone stage's unlock_rule) against a student's progress map to
// answer: current stage, completed/locked stages, next required scenario, percent complete,
// and what's blocking advancement. Rule-driven, not adaptive — see
// docs/PHASE3B_LEARNING_AND_ASSESSMENT.md §4 for why that's a deliberate scope line.
//
// Capstone completeness itself isn't computed here (a capstone stage has no scenario_ids to
// check against progress) — the caller supplies it after checking for a 'capstone_completed'
// achievement, since that's Postgres-backed state this module has no access to.
function isStageComplete(stage, progress) {
const ids = stage.scenario_ids || [];
if (!ids.length) return false;
const rule = stage.completion_rule || { type: 'all_required' };
const completedIds = ids.filter((id) => progress[id]?.status === 'completed');
switch (rule.type) {
case 'threshold':
return completedIds.length / ids.length >= (rule.percent || 100) / 100;
case 'prerequisites': {
const required = rule.scenario_ids && rule.scenario_ids.length ? rule.scenario_ids : ids;
return required.every((id) => progress[id]?.status === 'completed');
}
case 'all_required':
default:
return completedIds.length === ids.length;
}
}
function nextIncompleteScenario(stage, progress) {
return (stage.scenario_ids || []).find((id) => progress[id]?.status !== 'completed') || null;
}
// capstoneStatus: 'locked' | 'unlocked' | 'completed' — supplied by the caller (server route),
// since it depends on Postgres state (achievements) this module can't see.
function computeCurriculumState(path, progress, capstoneStatus) {
const contentStages = path.stages.filter((s) => s.type !== 'capstone');
const capstoneStage = path.stages.find((s) => s.type === 'capstone');
let priorComplete = true; // the first content stage is always unlocked
const stageCompletion = {}; // stage id -> bool, used by the capstone's stages_complete rule
const stages = contentStages.map((stage) => {
const complete = isStageComplete(stage, progress);
stageCompletion[stage.id] = complete;
const locked = !priorComplete;
if (!complete) priorComplete = false; // next stage stays locked until this one is done
const ids = stage.scenario_ids || [];
const completedCount = ids.filter((id) => progress[id]?.status === 'completed').length;
return {
id: stage.id, name: stage.name, icon: stage.icon, locked, complete,
total: ids.length, completed: completedCount,
pct: ids.length ? Math.round((completedCount / ids.length) * 100) : 0,
next_scenario: locked || complete ? null : nextIncompleteScenario(stage, progress),
};
});
const currentStage = stages.find((s) => !s.locked && !s.complete) || null;
const overallTotal = contentStages.reduce((n, s) => n + (s.scenario_ids || []).length, 0);
const overallCompleted = contentStages.reduce(
(n, s) => n + (s.scenario_ids || []).filter((id) => progress[id]?.status === 'completed').length, 0
);
let capstone = null;
if (capstoneStage) {
const rule = capstoneStage.unlock_rule || { type: 'stages_complete', stage_ids: contentStages.map((s) => s.id) };
const unlockedByRule = rule.type === 'stages_complete' && rule.stage_ids.every((id) => stageCompletion[id]);
const blocking = rule.type === 'stages_complete' ? rule.stage_ids.filter((id) => !stageCompletion[id]) : [];
capstone = {
id: capstoneStage.id, name: capstoneStage.name, icon: capstoneStage.icon,
assessment_definition_id: capstoneStage.capstone_assessment_id,
status: capstoneStatus || (unlockedByRule ? 'unlocked' : 'locked'),
blocking_stage_ids: blocking,
};
}
return {
stages, current_stage: currentStage?.id || null,
percent_complete: overallTotal ? Math.round((overallCompleted / overallTotal) * 100) : 0,
capstone,
};
}
module.exports = { computeCurriculumState, isStageComplete };
+222
View File
@@ -0,0 +1,222 @@
// Student-facing learning API — scenario attempt lifecycle (start/hint), curriculum state,
// assessments (list/start/active/detail/submit), capstone status, and achievement history.
// Everything here requires a student session (studentSession middleware, mounted by the
// caller) and derives identity/ownership entirely server-side — never from anything the client
// asserts. See docs/PHASE3B_LEARNING_AND_ASSESSMENT.md §19.
const attempts = require('./attempts');
const assessmentsLib = require('./assessments');
const achievementsLib = require('./achievements');
const { computeCurriculumState } = require('./curriculum-rules');
async function studentCohortId(pool, studentId) {
const { rows } = await pool.query(
`SELECT cohort_id FROM cohort_enrollments WHERE student_id = $1 AND enrollment_status = 'enrolled' ORDER BY enrolled_at LIMIT 1`,
[studentId]
);
return rows[0]?.cohort_id || null;
}
function findStageForScenario(path, scenarioId) {
return path?.stages.find((s) => (s.scenario_ids || []).includes(scenarioId))?.id || null;
}
async function getCapstoneAchievement(pool, studentId) {
const { rows } = await pool.query(
`SELECT * FROM achievements WHERE student_id = $1 AND achievement_type = 'capstone_completed' LIMIT 1`, [studentId]
);
return rows[0] || null;
}
module.exports = function registerLearningRoutes(app, deps) {
const { studentSession, getPool, curriculum, loadProgress, wrap } = deps;
// ── Scenario attempt lifecycle ──────────────────────────────────────────────────────────
app.post('/api/scenarios/:scenarioId/start', studentSession, wrap(async (req, res) => {
const pool = getPool();
const { environment_id } = req.body || {};
const path = curriculum.loadCurriculumPath();
const cohortId = await studentCohortId(pool, req.studentId);
const stage = findStageForScenario(path, req.params.scenarioId);
const assessmentCtx = await assessmentsLib.findActiveAssessmentContextForScenario(pool, req.studentId, req.params.scenarioId);
const attempt = await attempts.getOrCreateOpenAttempt(pool, {
studentId: req.studentId, scenarioId: req.params.scenarioId, environmentId: environment_id || null,
cohortId, curriculumStage: stage, assessmentSessionId: assessmentCtx?.session.assessment_session_id,
});
res.json({ attempt_id: attempt.attempt_id, status: attempt.status, started_at: attempt.started_at });
}));
app.get('/api/scenarios/:scenarioId/attempt', studentSession, wrap(async (req, res) => {
const attempt = await attempts.getAttemptSummary(getPool(), {
studentId: req.studentId, scenarioId: req.params.scenarioId, environmentId: req.query.environment_id,
});
const scenario = curriculum.loadScenarios().find((s) => s.id === req.params.scenarioId);
res.json({
attempt: attempt ? {
status: attempt.status, validation_attempt_count: attempt.validation_attempt_count,
validation_pass_count: attempt.validation_pass_count, validation_fail_count: attempt.validation_fail_count,
hint_count: attempt.hint_count, highest_hint_level: attempt.highest_hint_level,
duration_seconds: attempt.duration_seconds, started_at: attempt.started_at, completed_at: attempt.completed_at,
} : null,
total_hints: scenario?.hints?.length || 0,
});
}));
app.post('/api/scenarios/:scenarioId/hint', studentSession, wrap(async (req, res) => {
const pool = getPool();
const { environment_id } = req.body || {};
const scenario = curriculum.loadScenarios().find((s) => s.id === req.params.scenarioId);
if (!scenario) return res.status(404).json({ error: 'scenario_not_found' });
const hints = scenario.hints || [];
if (!hints.length) return res.status(404).json({ error: 'no_hints' });
const assessmentCtx = await assessmentsLib.findActiveAssessmentContextForScenario(pool, req.studentId, req.params.scenarioId);
if (assessmentCtx && assessmentCtx.session.hint_policy === 'disabled') {
return res.status(403).json({ error: 'hints_disabled', message: 'Hints are disabled for this assessment.' });
}
const path = curriculum.loadCurriculumPath();
const cohortId = await studentCohortId(pool, req.studentId);
const stage = findStageForScenario(path, req.params.scenarioId);
const result = await attempts.recordHintReveal(pool, {
studentId: req.studentId, scenarioId: req.params.scenarioId, environmentId: environment_id || null,
cohortId, curriculumStage: stage, assessmentSessionId: assessmentCtx?.session.assessment_session_id,
}, hints.length);
if (!result.ok) return res.status(409).json({ error: result.reason });
const { title, body, command } = hints[result.level - 1];
res.json({
hint: { title, body, command }, level: result.level, total_hints: hints.length,
flagged: !!(assessmentCtx && assessmentCtx.session.hint_policy === 'flagged'),
});
}));
// ── Curriculum state (extends /api/curriculum/path's response with rule-driven state) ─────
app.get('/api/curriculum/state', studentSession, wrap(async (req, res) => {
const pool = getPool();
const path = curriculum.loadCurriculumPath();
if (!path) return res.status(404).json({ error: 'curriculum_not_found' });
const progress = await loadProgress(pool, req.studentId);
const capstoneAchievement = await getCapstoneAchievement(pool, req.studentId);
const capstoneStatus = capstoneAchievement ? 'completed' : undefined; // undefined lets computeCurriculumState derive locked/unlocked from the rule
res.json(computeCurriculumState(path, progress, capstoneStatus));
}));
// ── Assessments ──────────────────────────────────────────────────────────────────────────
app.get('/api/assessments', studentSession, wrap(async (req, res) => {
const pool = getPool();
const cohortId = await studentCohortId(pool, req.studentId);
const { rows } = await pool.query(
`SELECT d.assessment_definition_id, d.type, d.title, d.description, d.duration_minutes, d.hint_policy
FROM assessment_definitions d
JOIN assessment_cohort_assignments a ON a.assessment_definition_id = d.assessment_definition_id
WHERE a.cohort_id = $1 AND d.status = 'active' AND d.type = 'exam'`,
[cohortId || '__none__']
);
const { rows: sessions } = await pool.query(
`SELECT assessment_definition_id, status, result, started_at, submitted_at, assessment_session_id
FROM assessment_sessions WHERE student_id = $1 ORDER BY created_at DESC`,
[req.studentId]
);
res.json(rows.map((d) => ({
...d,
sessions: sessions.filter((s) => s.assessment_definition_id === d.assessment_definition_id),
})));
}));
app.get('/api/assessments/active', studentSession, wrap(async (req, res) => {
const pool = getPool();
const session = await assessmentsLib.getActiveSessionForStudent(pool, req.studentId);
if (!session || session.status !== 'in_progress') return res.json({ active: false });
const scenarios = await assessmentsLib.getSessionScenarios(pool, session.assessment_session_id);
res.json({
active: true, assessment_session_id: session.assessment_session_id, type: session.type, title: session.title,
hint_policy: session.hint_policy, started_at: session.started_at, expires_at: session.expires_at,
scenarios: scenarios.map((s) => ({ scenario_id: s.scenario_id, position: s.position, status: s.status })),
});
}));
app.get('/api/assessments/:sessionId', studentSession, wrap(async (req, res) => {
const pool = getPool();
const { rows } = await pool.query(
`SELECT * FROM assessment_sessions WHERE assessment_session_id = $1 AND student_id = $2`,
[req.params.sessionId, req.studentId]
);
if (!rows[0]) return res.status(404).json({ error: 'not_found' });
const session = await assessmentsLib.lazyExpire(pool, rows[0]);
const scenarios = await assessmentsLib.getSessionScenarios(pool, session.assessment_session_id);
res.json({ ...session, scenarios });
}));
app.post('/api/assessments/:definitionId/start', studentSession, wrap(async (req, res) => {
const pool = getPool();
const { rows } = await pool.query(
`SELECT * FROM assessment_definitions WHERE assessment_definition_id = $1 AND status = 'active'`,
[req.params.definitionId]
);
const definition = rows[0];
if (!definition) return res.status(404).json({ error: 'not_found' });
const cohortId = await studentCohortId(pool, req.studentId);
try {
const session = await assessmentsLib.startSession(pool, { definition, studentId: req.studentId, cohortId });
const scenarios = await assessmentsLib.getSessionScenarios(pool, session.assessment_session_id);
res.status(201).json({
assessment_session_id: session.assessment_session_id, type: session.type, title: session.title,
started_at: session.started_at, expires_at: session.expires_at, hint_policy: session.hint_policy,
scenarios: scenarios.map((s) => ({ scenario_id: s.scenario_id, position: s.position, status: s.status })),
});
} catch (e) {
if (e.code === 'ALREADY_ACTIVE') return res.status(409).json({ error: 'already_active', message: e.message });
throw e;
}
}));
app.post('/api/assessments/:sessionId/submit', studentSession, wrap(async (req, res) => {
const pool = getPool();
const { rows } = await pool.query(
`SELECT * FROM assessment_sessions WHERE assessment_session_id = $1 AND student_id = $2`,
[req.params.sessionId, req.studentId]
);
if (!rows[0]) return res.status(404).json({ error: 'not_found' });
if (rows[0].status !== 'in_progress') return res.status(409).json({ error: 'not_in_progress' });
const session = await assessmentsLib.submitSession(pool, req.params.sessionId);
await achievementsLib.recordAssessmentAchievement(pool, session);
res.json(session);
}));
// ── Capstone (a thin, student-friendly view over curriculum state + the capstone definition) ─
app.get('/api/capstone', studentSession, wrap(async (req, res) => {
const pool = getPool();
const path = curriculum.loadCurriculumPath();
const progress = await loadProgress(pool, req.studentId);
const capstoneAchievement = await getCapstoneAchievement(pool, req.studentId);
const state = computeCurriculumState(path, progress, capstoneAchievement ? 'completed' : undefined);
if (!state.capstone) return res.status(404).json({ error: 'no_capstone_configured' });
const { rows } = await pool.query(
`SELECT assessment_definition_id, title, description, duration_minutes, hint_policy FROM assessment_definitions WHERE assessment_definition_id = $1`,
[state.capstone.assessment_definition_id]
);
const { rows: sessionRows } = await pool.query(
`SELECT assessment_session_id, status, started_at, expires_at, submitted_at, result FROM assessment_sessions
WHERE student_id = $1 AND assessment_definition_id = $2 ORDER BY created_at DESC`,
[req.studentId, state.capstone.assessment_definition_id]
);
res.json({ ...state.capstone, definition: rows[0] || null, sessions: sessionRows });
}));
// ── Achievements ─────────────────────────────────────────────────────────────────────────
app.get('/api/achievements', studentSession, wrap(async (req, res) => {
res.json(await achievementsLib.listAchievements(getPool(), req.studentId));
}));
// ── Recent attempts (for the student's own Practice view) ──────────────────────────────────
app.get('/api/attempts/recent', studentSession, wrap(async (req, res) => {
const { rows } = await getPool().query(
`SELECT scenario_id, status, result, validation_attempt_count, validation_pass_count,
validation_fail_count, hint_count, duration_seconds, started_at, completed_at
FROM scenario_attempts WHERE student_id = $1 ORDER BY started_at DESC NULLS LAST LIMIT 20`,
[req.studentId]
);
res.json(rows);
}));
};
+46 -8
View File
@@ -20,6 +20,10 @@ const { parseCookies, checkCsrf, makeCsrfToken } = require('./security');
const { buildCookie, clearCookie } = require('./cookies');
const createRbac = require('./rbac');
const { INSTRUCTOR_COOKIE, CSRF_COOKIE: INSTRUCTOR_CSRF_COOKIE } = require('./instructor-auth-routes');
const attemptsLib = require('./attempts');
const assessments = require('./assessments');
const achievements = require('./achievements');
const { computeCurriculumState } = require('./curriculum-rules');
const PORT = Number(process.env.PLATFORM_API_PORT) || 4300;
const STUDENT_COOKIE = 'rn_session';
@@ -167,24 +171,52 @@ app.get('/api/progress', wrap(studentSession), async (req, res) => {
});
app.post('/api/progress/record', wrap(studentSession), async (req, res) => {
const { scenario_id, status, environment_id, validation_count } = req.body || {};
const { scenario_id, status, environment_id } = req.body || {};
if (!scenario_id || !['completed', 'in_progress'].includes(status)) {
return res.status(400).json({ error: 'scenario_id and a valid status are required' });
}
const pool = getPool();
const passed = status === 'completed';
// Attempt telemetry (Phase 3B) — one row per continuous working session, updated in place.
const curriculumPath = curriculum.loadCurriculumPath();
const stage = curriculumPath?.stages.find((s) => (s.scenario_ids || []).includes(scenario_id))?.id || null;
const { rows: enrolledRows } = await pool.query(
`SELECT cohort_id FROM cohort_enrollments WHERE student_id = $1 AND enrollment_status = 'enrolled' ORDER BY enrolled_at LIMIT 1`,
[req.studentId]
);
const cohortId = enrolledRows[0]?.cohort_id || null;
const assessmentCtx = await assessments.findActiveAssessmentContextForScenario(pool, req.studentId, scenario_id);
const attempt = await attemptsLib.recordValidation(pool, {
studentId: req.studentId, scenarioId: scenario_id, environmentId: environment_id || null,
cohortId, curriculumStage: stage, assessmentSessionId: assessmentCtx?.session.assessment_session_id,
}, passed);
// Legacy aggregate progress row (Phase 1/2A/2B) — kept for the existing curriculum/dashboard
// views that read it; attempt-level detail above is the new source of truth for telemetry.
const progress = await loadProgress(pool, req.studentId);
const prev = progress[scenario_id] || { attempts: 0 };
const now = new Date().toISOString();
await saveOneProgress(pool, req.studentId, scenario_id, {
...prev, status, attempts: (prev.attempts || 0) + 1, last_validated: now,
completed_at: status === 'completed' ? now : prev.completed_at,
completed_at: passed ? now : prev.completed_at,
});
await pool.query(
`INSERT INTO scenario_attempts (student_id, scenario_id, environment_id, completed_at, result, validation_count)
VALUES ($1, $2, $3, now(), $4, $5)`,
[req.studentId, scenario_id, environment_id || null, status === 'completed' ? 'passed' : 'failed', validation_count ?? null]
);
await touchStudent(pool, req.studentId);
if (passed) {
if (assessmentCtx) {
const finalizedSession = await assessments.recordScenarioOutcome(pool, assessmentCtx.session.assessment_session_id, scenario_id, attempt.attempt_id, true);
await achievements.recordAssessmentAchievement(pool, finalizedSession);
}
if (curriculumPath) {
const freshProgress = await loadProgress(pool, req.studentId);
const state = computeCurriculumState(curriculumPath, freshProgress);
await achievements.checkStageAndCurriculumAchievements(pool, { studentId: req.studentId, path: curriculumPath, curriculumState: state });
}
} else if (assessmentCtx) {
await assessments.recordScenarioOutcome(pool, assessmentCtx.session.assessment_session_id, scenario_id, attempt.attempt_id, false);
}
res.json({ ok: true });
});
@@ -249,10 +281,16 @@ app.get('/api/environment/by-token/:token', async (req, res) => {
require('./instructor-auth-routes')(app, { pool: getPool(), sessions, instructorSession: wrap(instructorSession) });
require('./learning-routes')(app, { studentSession, getPool, curriculum, loadProgress, wrap });
const rbac = createRbac(getPool());
require('./admin-routes')(app, {
instructorSession: wrap(instructorSession), rbac: createRbac(getPool()), provisioner, curriculum,
instructorSession: wrap(instructorSession), rbac, provisioner, curriculum,
getPool, loadProgress, saveOneProgress, recordAudit,
});
require('./admin-learning-routes')(app, {
instructorSession: wrap(instructorSession), rbac, curriculum, getPool, loadProgress, recordAudit,
});
// Fallback to the platform-shell SPA for any unmatched non-API route
app.get('*', (req, res) => {
+803
View File
@@ -0,0 +1,803 @@
{
"name": "rootnode-gateway",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "rootnode-gateway",
"version": "0.1.0",
"dependencies": {
"express": "^4.19.2",
"http-proxy": "^1.18.1"
}
},
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
"dependencies": {
"mime-types": "~2.1.34",
"negotiator": "0.6.3"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="
},
"node_modules/body-parser": {
"version": "1.20.6",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz",
"integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==",
"dependencies": {
"bytes": "~3.1.2",
"content-type": "~1.0.5",
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "~1.2.0",
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"on-finished": "~2.4.1",
"qs": "~6.15.1",
"raw-body": "~2.5.3",
"type-is": "~1.6.18",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/call-bound": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
"dependencies": {
"safe-buffer": "5.2.1"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/content-type": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-signature": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA=="
},
"node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/destroy": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="
},
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="
},
"node_modules/etag": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/eventemitter3": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="
},
"node_modules/express": {
"version": "4.22.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
"integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
"body-parser": "~1.20.5",
"content-disposition": "~0.5.4",
"content-type": "~1.0.4",
"cookie": "~0.7.1",
"cookie-signature": "~1.0.6",
"debug": "2.6.9",
"depd": "2.0.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"finalhandler": "~1.3.1",
"fresh": "~0.5.2",
"http-errors": "~2.0.0",
"merge-descriptors": "1.0.3",
"methods": "~1.1.2",
"on-finished": "~2.4.1",
"parseurl": "~1.3.3",
"path-to-regexp": "~0.1.12",
"proxy-addr": "~2.0.7",
"qs": "~6.15.1",
"range-parser": "~1.2.1",
"safe-buffer": "5.2.1",
"send": "~0.19.0",
"serve-static": "~1.16.2",
"setprototypeof": "1.2.0",
"statuses": "~2.0.1",
"type-is": "~1.6.18",
"utils-merge": "1.0.1",
"vary": "~1.1.2"
},
"engines": {
"node": ">= 0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/finalhandler": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
"integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
"dependencies": {
"debug": "2.6.9",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"on-finished": "~2.4.1",
"parseurl": "~1.3.3",
"statuses": "~2.0.2",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/follow-redirects": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/fresh": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
"dependencies": {
"depd": "~2.0.0",
"inherits": "~2.0.4",
"setprototypeof": "~1.2.0",
"statuses": "~2.0.2",
"toidentifier": "~1.0.1"
},
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/http-proxy": {
"version": "1.18.1",
"resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz",
"integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==",
"dependencies": {
"eventemitter3": "^4.0.0",
"follow-redirects": "^1.0.0",
"requires-port": "^1.0.0"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/merge-descriptors": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
"integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/methods": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
"bin": {
"mime": "cli.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
},
"node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
"dependencies": {
"ee-first": "1.1.1"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/path-to-regexp": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA=="
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
"dependencies": {
"forwarded": "0.2.0",
"ipaddr.js": "1.9.1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/qs": {
"version": "6.15.3",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
"integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
"dependencies": {
"es-define-property": "^1.0.1",
"side-channel": "^1.1.1"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/raw-body": {
"version": "2.5.3",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
"integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
"dependencies": {
"bytes": "~3.1.2",
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/requires-port": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ=="
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
]
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
},
"node_modules/send": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
"integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
"dependencies": {
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "1.2.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"fresh": "~0.5.2",
"http-errors": "~2.0.1",
"mime": "1.6.0",
"ms": "2.1.3",
"on-finished": "~2.4.1",
"range-parser": "~1.2.1",
"statuses": "~2.0.2"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/send/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
},
"node_modules/serve-static": {
"version": "1.16.3",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
"integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
"dependencies": {
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"parseurl": "~1.3.3",
"send": "~0.19.1"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="
},
"node_modules/side-channel": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
"integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.4",
"side-channel-list": "^1.0.1",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-list": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.4"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-map": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-weakmap": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3",
"side-channel-map": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
"engines": {
"node": ">=0.6"
}
},
"node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
"dependencies": {
"media-typer": "0.3.0",
"mime-types": "~2.1.24"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
"engines": {
"node": ">= 0.8"
}
}
}
}
+60
View File
@@ -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;
File diff suppressed because it is too large Load Diff
+293
View File
@@ -0,0 +1,293 @@
#!/usr/bin/env node
// Phase 3B — Learning & Assessment Engine acceptance test. Covers attempt telemetry,
// progressive hints, curriculum stage/capstone unlock rules, the exam engine (random draw,
// timer, expiry, restart persistence), the capstone workflow end to end, two-student isolation,
// and instructor learning analytics/RBAC. Runs against a live platform/docker-compose.dev.yml
// stack — slow (real k3s boots + a real API restart). No framework, matching project
// convention.
//
// Usage: cd platform && docker compose -f docker-compose.dev.yml up -d
// (cd api && PLATFORM_DB_URL=... node bootstrap-owner.js OWNER_EMAIL "Owner" OWNER_PASSWORD)
// OWNER_EMAIL=... OWNER_PASSWORD=... node test/e2e-phase3b.js
const assert = require('assert');
const { execSync } = require('child_process');
const { makeSession } = require('./session-helper');
const GW = process.env.GATEWAY_URL || 'http://localhost:4200';
const API = process.env.PLATFORM_API_URL || 'http://localhost:4300';
const OWNER_EMAIL = process.env.OWNER_EMAIL;
const OWNER_PASSWORD = process.env.OWNER_PASSWORD;
const SKIP_RESTART_TEST = process.env.SKIP_RESTART_TEST === '1';
if (!OWNER_EMAIL || !OWNER_PASSWORD) {
console.error('Set OWNER_EMAIL and OWNER_PASSWORD (bootstrap one first with platform/api/bootstrap-owner.js)');
process.exit(1);
}
const RUN_ID = Date.now();
const COHORT_ID = `e2e-3b-${RUN_ID}`;
const FOUNDATION = ['pod-basics-mcq', 'kubectl-essentials-mcq', 'labels-selectors-mcq', 'init-containers-mcq', 'namespaces-basics',
'multi-container-pod-basics', 'pod-labeling-basics', 'jsonpath-basics', 'dry-run-manifest-basics', 'delete-by-label-basics',
'annotate-resource-basics', 'init-container-task-basics', 'restart-policy-mcq', 'kubectl-output-format-mcq',
'kubectl-explain-basics', 'kubectl-patch-basics', 'field-selector-basics', 'sort-by-basics', 'kubectl-diff-basics', 'static-pods-mcq'];
const WORKLOADS = ['deploy-nginx', 'scale-deployment', 'rolling-update-task', 'liveness-probe-task', 'jobs-cronjobs-mcq', 'daemonset-mcq', 'edit-deployment-basics', 'deployment-strategy-mcq'];
const NETWORKING = ['services-mcq', 'expose-service-basics'];
const CONFIGURATION = ['configmap-volume', 'resource-limits-task', 'env-vars-configmap', 'secrets-basics', 'pod-env-vars-inline'];
const TROUBLESHOOTING = ['extract-logs-basics', 'kubectl-cp-basics', 'exec-command-basics', 'node-cordon-basics', 'pod-node-assignment-mcq'];
const REAL_OPS = ['rootnode-runaway-monitor', 'broken-deployment', 'rootnode-probe-port-mismatch', 'rootnode-enforce-limits'];
const ALL_NON_FOUNDATION = [...WORKLOADS, ...NETWORKING, ...CONFIGURATION, ...TROUBLESHOOTING, ...REAL_OPS];
let passed = 0, failed = 0;
async function test(name, fn) {
try { await fn(); console.log(` ok — ${name}`); passed++; }
catch (e) { console.error(` FAIL — ${name}\n ${e.message}`); failed++; }
}
async function complete(session, scenarioId, envId) {
const { status } = await session.request('POST', '/api/progress/record', { scenario_id: scenarioId, status: 'completed', environment_id: envId });
assert.equal(status, 200, `completing ${scenarioId} should 200`);
}
async function fail(session, scenarioId, envId) {
const { status } = await session.request('POST', '/api/progress/record', { scenario_id: scenarioId, status: 'in_progress', environment_id: envId });
assert.equal(status, 200);
}
async function main() {
console.log('Phase 3B learning/assessment acceptance test — slow, real k3s boots + a real API restart\n');
const owner = makeSession(GW);
await owner.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: OWNER_PASSWORD });
await test('setup: cohort + two students + Prepare Labs', async () => {
assert.equal((await owner.request('POST', '/api/admin/cohorts', { cohort_id: COHORT_ID, name: 'E2E 3B' })).status, 201);
const a = await owner.request('POST', '/api/admin/students', { display_name: `E2E-3B-Alice-${RUN_ID}`, cohort_id: COHORT_ID });
const b = await owner.request('POST', '/api/admin/students', { display_name: `E2E-3B-Bob-${RUN_ID}`, cohort_id: COHORT_ID });
global.__alice = a.body.student_id; global.__bob = b.body.student_id;
const { status, body } = await owner.request('POST', `/api/admin/cohorts/${COHORT_ID}/prepare-labs`);
assert.equal(status, 200);
assert.ok(body.results.every((r) => r.status === 'running'), `expected all running: ${JSON.stringify(body.results)}`);
});
const alice = makeSession(GW);
const bob = makeSession(GW);
let aliceEnv, bobEnv;
await test('students log in and launch their prepared labs', async () => {
await alice.request('POST', '/api/login', { student_id: global.__alice });
await bob.request('POST', '/api/login', { student_id: global.__bob });
const a = await alice.request('POST', '/api/environment/launch');
const b = await bob.request('POST', '/api/environment/launch');
assert.equal(a.body.status, 'running'); assert.equal(b.body.status, 'running');
aliceEnv = a.body.launch_path.split('/')[2];
bobEnv = b.body.launch_path.split('/')[2];
// resolve environment_id via the admin view (routing token isn't the environment_id)
const aEnvRow = (await owner.request('GET', `/api/admin/students/${global.__alice}/environment`)).body.find((e) => e.status === 'running');
const bEnvRow = (await owner.request('GET', `/api/admin/students/${global.__bob}/environment`)).body.find((e) => e.status === 'running');
global.__aliceEnvId = aEnvRow.environment_id; global.__bobEnvId = bEnvRow.environment_id;
});
// ── Attempt telemetry ────────────────────────────────────────────────────────────────────
await test('learning records: start, fail, pass — telemetry counters correct', async () => {
const sid = FOUNDATION[0];
await alice.request('POST', `/api/scenarios/${sid}/start`, { environment_id: global.__aliceEnvId });
await fail(alice, sid, global.__aliceEnvId);
await fail(alice, sid, global.__aliceEnvId);
await complete(alice, sid, global.__aliceEnvId);
const { body } = await alice.request('GET', `/api/scenarios/${sid}/attempt?environment_id=${global.__aliceEnvId}`);
assert.equal(body.attempt.status, 'completed');
assert.equal(body.attempt.validation_attempt_count, 3);
assert.equal(body.attempt.validation_pass_count, 1);
assert.equal(body.attempt.validation_fail_count, 2);
assert.ok(body.attempt.duration_seconds !== null);
});
// ── Progressive hints ────────────────────────────────────────────────────────────────────
await test('hints: sequential reveal, exhaustion, and persistence after Lab reset', async () => {
const sid = 'namespaces-basics'; // has 2 hints — needed to exercise sequential reveal past level 1
await alice.request('POST', `/api/scenarios/${sid}/start`, { environment_id: global.__aliceEnvId });
const h1 = await alice.request('POST', `/api/scenarios/${sid}/hint`, { environment_id: global.__aliceEnvId });
assert.equal(h1.status, 200); assert.equal(h1.body.level, 1);
const h2 = await alice.request('POST', `/api/scenarios/${sid}/hint`, { environment_id: global.__aliceEnvId });
assert.equal(h2.body.level, 2);
// Lab reset must not erase attempt/hint state (Phase 3B §9) — the attempt lives in Platform
// Postgres, keyed by environment_id, which survives a reset (same env row, new container).
const resetRes = await alice.request('POST', '/api/environment/reset');
assert.equal(resetRes.body.status, 'running');
const { body: attemptAfterReset } = await alice.request('GET', `/api/scenarios/${sid}/attempt?environment_id=${global.__aliceEnvId}`);
assert.equal(attemptAfterReset.attempt.highest_hint_level, 2);
assert.equal(attemptAfterReset.attempt.hint_count, 2);
});
// ── Curriculum completion + stage unlock ────────────────────────────────────────────────
await test('curriculum: stage locked until prerequisites, unlocks on completion', async () => {
const before = await alice.request('GET', '/api/curriculum/state');
assert.equal(before.body.stages.find((s) => s.id === 'workloads').locked, true);
for (const sid of FOUNDATION) await complete(alice, sid, global.__aliceEnvId);
const after = await alice.request('GET', '/api/curriculum/state');
assert.equal(after.body.stages.find((s) => s.id === 'foundation').complete, true);
assert.equal(after.body.stages.find((s) => s.id === 'workloads').locked, false);
assert.equal(after.body.current_stage, 'workloads');
});
await test('achievements: stage_completed recorded for Foundation', async () => {
const { body } = await alice.request('GET', '/api/achievements');
assert.ok(body.some((a) => a.achievement_type === 'stage_completed' && a.stage_id === 'foundation'));
});
// ── Capstone lock/unlock ─────────────────────────────────────────────────────────────────
await test('capstone: locked before all stages complete', async () => {
const { body } = await alice.request('GET', '/api/capstone');
assert.equal(body.status, 'locked');
assert.ok(body.blocking_stage_ids.length > 0);
});
await test('capstone: unlocks once every prerequisite stage is complete', async () => {
for (const sid of ALL_NON_FOUNDATION) await complete(alice, sid, global.__aliceEnvId);
const { body } = await alice.request('GET', '/api/capstone');
assert.equal(body.status, 'unlocked');
assert.equal(body.blocking_stage_ids.length, 0);
});
let capstoneScenarios;
await test('capstone: start draws a scenario set featuring Real Ops prominently', async () => {
const { status, body } = await alice.request('POST', '/api/assessments/rootnode-capstone/start');
assert.equal(status, 201);
capstoneScenarios = body.scenarios.map((s) => s.scenario_id);
assert.equal(capstoneScenarios.length, 7); // 4 required Real Ops + 3 random
for (const req of REAL_OPS) assert.ok(capstoneScenarios.includes(req), `capstone draw missing required ${req}`);
});
await test('capstone: cannot start a second assessment while one is active', async () => {
const { status, body } = await alice.request('POST', '/api/assessments/rootnode-quick-assessment/start');
assert.equal(status, 409); assert.equal(body.error, 'already_active');
});
await test('capstone: completing all drawn scenarios finalizes the session and records an achievement', async () => {
for (const sid of capstoneScenarios) await complete(alice, sid, global.__aliceEnvId);
const { body } = await alice.request('GET', '/api/capstone');
assert.equal(body.status, 'completed');
const ach = await alice.request('GET', '/api/achievements');
assert.ok(ach.body.some((a) => a.achievement_type === 'capstone_completed'));
});
// ── Exam engine (Bob) ────────────────────────────────────────────────────────────────────
let examSessionId, examScenarios, examExpiresAt;
await test('exam: assign to cohort, start, random draw matches the definition\'s pool spec', async () => {
assert.equal((await owner.request('POST', '/api/admin/assessments/rootnode-quick-assessment/assign', { cohort_id: COHORT_ID })).status, 200);
const { status, body } = await bob.request('POST', '/api/assessments/rootnode-quick-assessment/start');
assert.equal(status, 201);
examSessionId = body.assessment_session_id; examScenarios = body.scenarios.map((s) => s.scenario_id); examExpiresAt = body.expires_at;
assert.equal(examScenarios.length, 5); // 2 + 1 + 1 + 1 per the seeded pool_spec
assert.ok(examScenarios.some((s) => REAL_OPS.includes(s)));
});
await test('exam: student sees own active assessment with a live timer', async () => {
const { body } = await bob.request('GET', '/api/assessments/active');
assert.equal(body.active, true);
assert.equal(body.assessment_session_id, examSessionId);
});
await test('exam: hints are disabled per the definition\'s hint_policy', async () => {
const sid = examScenarios[0];
await bob.request('POST', `/api/scenarios/${sid}/start`, { environment_id: global.__bobEnvId });
const { status, body } = await bob.request('POST', `/api/scenarios/${sid}/hint`, { environment_id: global.__bobEnvId });
assert.equal(status, 403); assert.equal(body.error, 'hints_disabled');
});
await test('security: a student cannot read another student\'s assessment session', async () => {
const { status } = await alice.request('GET', `/api/assessments/${examSessionId}`);
assert.equal(status, 404); // exists, but not owned by Alice — same "don't confirm existence" pattern as RBAC 403s
});
await test('two students: distinct sessions, distinct draws, no cross-contamination', async () => {
assert.notEqual(examSessionId, undefined);
const aliceActive = await alice.request('GET', '/api/assessments/active');
assert.equal(aliceActive.body.active, false); // Alice already finished her capstone
assert.deepEqual([...examScenarios].sort(), [...new Set(examScenarios)].sort()); // no duplicate scenario ids in Bob's draw
});
await test('exam: completing every drawn scenario finalizes the session', async () => {
for (const sid of examScenarios) await complete(bob, sid, global.__bobEnvId);
const { body } = await bob.request('GET', `/api/assessments/${examSessionId}`);
assert.equal(body.status, 'completed');
assert.equal(body.result.scenarios_passed, examScenarios.length);
const ach = await bob.request('GET', '/api/achievements');
assert.ok(ach.body.some((a) => a.achievement_type === 'assessment_completed'));
});
// ── Timer integrity: a fresh short exam, verify Lab reset doesn't touch the timer ─────────
let shortExamId, shortExamExpiry;
await test('timer: Lab reset mid-assessment does not change expires_at', async () => {
await owner.request('POST', '/api/admin/assessments', {
id: `e2e-timer-${RUN_ID}`, type: 'exam', title: 'Timer Test', duration_minutes: 30,
hint_policy: 'free', pool_spec: { required: [FOUNDATION[2]], random: [] },
});
await owner.request('POST', `/api/admin/assessments/e2e-timer-${RUN_ID}/assign`, { cohort_id: COHORT_ID });
const start = await bob.request('POST', `/api/assessments/e2e-timer-${RUN_ID}/start`);
shortExamId = start.body.assessment_session_id; shortExamExpiry = start.body.expires_at;
await bob.request('POST', '/api/environment/reset');
const { body } = await bob.request('GET', `/api/assessments/${shortExamId}`);
assert.equal(body.expires_at, shortExamExpiry, 'timer must be Platform-authoritative, unaffected by Lab recreation');
assert.equal(body.status, 'in_progress');
});
// ── Session persistence across a real restart ────────────────────────────────────────────
if (!SKIP_RESTART_TEST) {
await test('assessment session survives a real Platform API restart', async () => {
execSync('docker compose -f docker-compose.dev.yml restart api', { cwd: __dirname + '/..', stdio: 'pipe' });
let up = false;
for (let i = 0; i < 30 && !up; i++) {
await new Promise((r) => setTimeout(r, 1000));
try { up = (await fetch(`${API}/api/roster/cohorts`)).ok; } catch { /* still restarting */ }
}
assert.ok(up, 'Platform API did not come back up in time');
const { body } = await bob.request('GET', `/api/assessments/${shortExamId}`);
assert.equal(body.status, 'in_progress');
assert.equal(body.expires_at, shortExamExpiry);
});
} else {
console.log(' (skipped) assessment session survives a real Platform API restart — SKIP_RESTART_TEST=1');
}
// ── Instructor analytics + RBAC ──────────────────────────────────────────────────────────
await test('instructor: cohort learning summary reflects real completion data', async () => {
const { body } = await owner.request('GET', `/api/admin/cohorts/${COHORT_ID}/learning`);
assert.equal(body.student_count, 2);
// Only Alice worked the curriculum path — Bob only ever did the exam (drawn from
// workloads/networking/troubleshooting/real-ops, never Foundation) — so this also confirms
// completion counts are genuinely per-student, not just "any activity happened."
assert.equal(body.stages.find((s) => s.id === 'foundation').completedBy, 1);
});
await test('instructor: student detail shows attempts, hints, assessment history', async () => {
const { body } = await owner.request('GET', `/api/admin/students/${global.__alice}/learning`);
assert.ok(body.attempts.length > 0);
assert.ok(body.assessment_sessions.some((s) => s.type === 'capstone' && s.status === 'completed'));
});
await test('instructor: needs-attention returns reasonable signals, no punitive labels', async () => {
const { body } = await owner.request('GET', `/api/admin/cohorts/${COHORT_ID}/needs-attention`);
assert.ok(Array.isArray(body));
assert.ok(body.every((s) => !/bad|fail(ed)? student|stupid/i.test(s.label)));
});
await test('RBAC: a plain instructor is scoped to their own cohort for learning routes', async () => {
const inst = await owner.request('POST', '/api/admin/instructors', { email: `teach3b-${RUN_ID}@rootnode.test`, display_name: 'Teach 3B', role: 'instructor', password: 'TeacherPassword3B1' });
const otherCohort = `e2e-3b-other-${RUN_ID}`;
await owner.request('POST', '/api/admin/cohorts', { cohort_id: otherCohort, name: 'Other' });
const teacher = makeSession(GW);
await teacher.request('POST', '/api/auth/instructor/login', { email: `teach3b-${RUN_ID}@rootnode.test`, password: 'TeacherPassword3B1' });
assert.equal((await teacher.request('GET', `/api/admin/cohorts/${otherCohort}/learning`)).status, 403);
await owner.request('POST', `/api/admin/cohorts/${COHORT_ID}/instructors`, { instructor_id: inst.body.instructor_id });
assert.equal((await teacher.request('GET', `/api/admin/cohorts/${COHORT_ID}/learning`)).status, 200);
});
await test('regression: no Lab container publishes a host port', async () => {
const { body: envs } = await owner.request('GET', `/api/admin/students/${global.__alice}/environment`);
assert.ok(envs.every((e) => !e.internal_port || e.internal_port === 80));
});
console.log(`\n${passed} passed, ${failed} failed`);
process.exit(failed ? 1 : 0);
}
main().catch((e) => { console.error('Test run crashed:', e); process.exit(1); });
+10 -1
View File
@@ -2,11 +2,13 @@
"id": "rootnode-path",
"name": "RootNode Learning Path",
"tagline": "The guided route through RootNode Academy — one stage at a time.",
"version": 1,
"stages": [
{
"id": "foundation",
"name": "Foundation",
"icon": "🌱",
"completion_rule": { "type": "all_required" },
"scenario_ids": [
"pod-basics-mcq",
"kubectl-essentials-mcq",
@@ -34,6 +36,7 @@
"id": "workloads",
"name": "Workloads",
"icon": "📦",
"completion_rule": { "type": "all_required" },
"scenario_ids": [
"deploy-nginx",
"scale-deployment",
@@ -49,6 +52,7 @@
"id": "networking",
"name": "Networking",
"icon": "🔌",
"completion_rule": { "type": "all_required" },
"scenario_ids": [
"services-mcq",
"expose-service-basics"
@@ -58,6 +62,7 @@
"id": "configuration",
"name": "Configuration",
"icon": "⚙️",
"completion_rule": { "type": "all_required" },
"scenario_ids": [
"configmap-volume",
"resource-limits-task",
@@ -70,6 +75,7 @@
"id": "troubleshooting",
"name": "Troubleshooting",
"icon": "🔍",
"completion_rule": { "type": "all_required" },
"scenario_ids": [
"extract-logs-basics",
"kubectl-cp-basics",
@@ -83,6 +89,7 @@
"name": "RootNode: Real Ops",
"icon": "🌳",
"bundle_id": "rootnode-real-ops",
"completion_rule": { "type": "all_required" },
"scenario_ids": [
"rootnode-runaway-monitor",
"broken-deployment",
@@ -94,7 +101,9 @@
"id": "capstone",
"name": "Capstone",
"icon": "🏁",
"locked": true,
"type": "capstone",
"capstone_assessment_id": "rootnode-capstone",
"unlock_rule": { "type": "stages_complete", "stage_ids": ["foundation", "workloads", "networking", "configuration", "troubleshooting", "real-ops"] },
"scenario_ids": []
}
]