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.
16 KiB
RootNode Academy — Engineering SOP
Living doc. Covers the "how" of building this platform — the "why" and business/curriculum
process live in the Business Pack (~/Documents/CDAA Technologies/07 RootNode academy/RootNode_Academy_Business_Pack/),
this doc doesn't duplicate that. Update this file in the same commit as the change it describes.
1. Where this actually lives
- Repo:
~/Dev/Gitea/ced4568/rootnode-academy— remote is Gitea (git.synthossystems.com/ced4568/rootnode-academy), not GitHub. - Gotcha:
~/Dev/GitHub/ced4568/kubekoshis a different clone of the same original upstream fork, still pointed at GitHub, and it's behind (missing the RootNode logo commit and the Real Ops enforce-limits scenario as of this writing). It's not deployed anywhere. Don't edit it by mistake — always confirmgit remote -vshowsgitea:before making changes. - Known-good baseline as of this doc: commit
6ddfb1c("add probe-port-mismatch and enforce-limits to rootnode-real-ops bundle"). Everything below builds on top of that, clean working tree.
2. Architecture at a glance
backend/server.js Express API + SQLite (better-sqlite3) + WebSocket PTY terminal — the
"Lab image," single-tenant, unmodified by the Platform work below
frontend/ Vite + React SPA, built to frontend/dist and served by nginx
scenarios/data/ One JSON file per scenario (task or MCQ)
scenarios/bundles/ Flat scenario_id lists grouped by cert track (basics/appdev/admin/security/real-ops)
scenarios/rootnode-path.json The RootNode guided learning path (staged, see §4)
scripts/entrypoint.sh Container boot: starts k3s, nginx, node backend, sets the shell banner/PS1
Dockerfile ubuntu:22.04 + k3s + node20 + nginx, single image, one port (80)
platform/provisioner/ Phase 1 Platform Provisioner — disposable per-student Lab lifecycle,
PostgreSQL registry, egress firewall. See §8 and
platform/provisioner/README.md.
platform/egress-proxy/ Shared allowlisting egress proxy every Lab environment's traffic must
pass through.
Single Express process, single SQLite file at /data/progress.db (or $PROGRESS_DB),
single embedded k3s cluster per Lab environment. The Lab image itself is still
single-tenant by design — see §5 for identity, and §8 for how Phase 1 multiplies it into a
real multi-student platform without modifying it.
3. Local dev / safe smoke-testing
Never test schema changes against the real /data/progress.db first. Point at a scratch file:
cd backend && npm install # first time only
node --check server.js # syntax check, catches typos fast
PROGRESS_DB=/tmp/scratch/progress.db node server.js &
curl -s http://localhost:4000/api/health
# ...exercise the endpoints you changed...
fuser -k 4000/tcp # stop it
To test a migration against real prior data without touching prod: copy the actual
/data/progress.db out of the running container/volume, point PROGRESS_DB at the copy,
boot, and verify the data you expect is still there before ever pointing at the real file.
kubectl/k3s-backed endpoints (/setup, /validate, /context, the WS terminal) won't work
outside the container — that's expected. API-and-DB-only endpoints (roster, login, progress,
curriculum, bundles, scenarios list) all smoke-test fine on a bare host.
4. Curriculum content
Two separate structures, don't confuse them:
- Bundles (
scenarios/bundles/*.json) — flat scenario_id lists, one per cert track. Loaded byloadBundles()/GET /api/bundles. Adding a bundle file here = adding a new cert-track library. Every scenario referenced must have a matching file inscenarios/data/. - 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 inscenarios/bundles/). Loaded byloadCurriculumPath()/GET /api/curriculum/path. Stages are Foundation → Workloads → Networking → Configuration → Troubleshooting → Real Ops → Capstone, currently built by reusing existing1-k8s-basicsandrootnode-real-opsscenarios grouped by their existingcategoryfield — no new scenario content was written for this. Capstone stage is a locked placeholder ("locked": true, emptyscenario_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.
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
POST /api/cache/reload or on server restart.
5. Student identity — what's built, what isn't
Built (backend only, as of this doc):
studentstable:student_id(student-NNN, sequential),display_name,cohort,created_at,last_active_at.- Login model: instructor roster, pick-your-name, no password.
POST /api/rosteradds a student (instructor-only action, currently unauthenticated at the API level — see "not built" below).GET /api/roster?cohort=Xlists names for a dropdown.POST /api/loginjust validates the id exists and returns it; the frontend is expected to hold ontostudent_idand send it back as anX-Student-Idheader on every request. progresstable now keyed on(student_id, scenario_id)instead ofscenario_idalone — each student's completion/attempts/hints are isolated. Verified by smoke test: two students answering the same scenario don't affect each other's state, and pre-migration data was preserved understudent_id = 'unassigned'.- Exam
sessionsnow carrystudent_id(set at session start), so exam history and the timer-reset-on-submit logic are correctly scoped per student instead of leaking across whoever's browser hits the API next.
Not built yet — do not assume these exist:
- No frontend UI. No login screen, no name picker, no roster-add form. The API works; nothing calls it yet. This is the next slice.
- No auth on
/api/rosterPOST. Anyone who can reach the API can add a "student." Fine for a dev instance, not fine once this is reachable by actual students — needs an instructor-only gate before that happens (ties into the instructor dashboard, roadmap item 3). - No concurrency / isolation. Exam sessions are still global-single-active
(
WHERE status='active' LIMIT 1— seeserver.js), and there is one shared k3s cluster for the whole instance. Per-student identity ≠ per-student environment. Multiple students cannot yet use this platform at the same time without stepping on each other. That's roadmap item 4 (isolation) and it gates real classroom/minors use, same as the plan called out — this SOP is not claiming that gate is cleared.
6. Schema migration pattern
SQLite can't ALTER a primary key. The codebase's existing convention (see progress table
history) is:
PRAGMA table_info(<table>)→ check if the new column/shape is already there (idempotent, safe to run on every boot).- Simple new nullable column →
ALTER TABLE ... ADD COLUMN. - Primary-key or structural change → build
<table>_newwith the new shape,INSERT ... SELECTthe old data across with sane defaults for new required columns,DROP TABLE,RENAME TO. Never drop data — give it a legacy/default value ('unassigned', etc.) instead of discarding rows. - Test the migration against a copy of real data (§3) before it ever runs against
/data/progress.dbin the container.
7. Roadmap status
Order agreed 2026-08-17/18. Status as of this doc:
- 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. - RootNode curriculum mode — backend done (§4), frontend not started.
- 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 (docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md §7).
- Instructor dashboard — done (§9/§10). Full admin portal at
/admin: dashboard, cohort and student management, classroom Prepare/Destroy Labs, per-instructor accounts with owner/admin/instructor roles, audit log. - Per-student isolation — Phase 1 + Phase 2A + Phase 2B + Phase 3A done, see §8/§9/§10. Ephemeral per-student Lab environments, Gateway-mediated routing with persistent server-controlled sessions, a full instructor/admin control plane with real per-person accounts and RBAC, and a working entry→dashboard→launch→lab→progress flow are implemented and end-to-end tested on a single dev machine, including a 3-student cohort classroom workflow and a real Platform API restart mid-session. Still not cleared for minors — the production two-VM Proxmox split this depends on doesn't exist yet, and OIDC/SSO is a documented adapter, not a working integration (docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md §16).
- Scenarios → teaching modules (incident context/investigation/prevention) — not started.
- Capstone/exam engine — not started (placeholder stage only, see §4).
- Progressive hint levels — not started.
- Production observability — not started.
- CI scenario testing — not started.
- 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.
8. Platform Provisioner (Phase 1)
Full design: docs/LAB_ARCHITECTURE.md + docs/PHASE1_PLATFORM_FOUNDATION.md. Operational
reference (config, local dev, known limitations): platform/provisioner/README.md. This
section is just the map.
- What it is: an internal-only service that provisions a disposable, fully isolated Lab
environment per student — its own Docker network, its own container running the existing,
unmodified Lab image, its own firewall-enforced egress. Postgres-backed registry
(
environments,environment_events,lab_hosts), idle reaper, startup reconciliation. - What it is not: directly reachable by a student or the Gateway anymore — as of Phase 2B,
the Platform API (§9) is the only caller (student launch/reset AND admin prepare/destroy/
reset both go through it). Still exercised directly via its internal API (shared-secret
auth) or
platform/provisioner/test/run.jsfor isolated Provisioner testing. - Where identity lives: as of Phase 2B, Postgres is authoritative —
students/cohorts/progress/scenario_attemptslive in the samerootnode_platformdatabase asenvironments, owned by the Platform API's own migrations. See §9. As of Phase 3A,instructors/sessions/cohort_instructors/audit_eventslive there too. See §10.environments.student_idis still a plain opaque reference, not a real FK — that's a deliberate, documented choice (docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md §2), not an oversight. - Local dev:
platform/provisioner/docker-compose.dev.ymlruns Postgres + the egress proxy + the Provisioner on one machine — the documented Phase 1 dev exception. Production is a two-VM Proxmox split (Platform VM / Lab Host VM); the dev compose file is explicitly not that. - Test procedure:
platform/provisioner/test/run.js— 12 automated integration tests against a live dev stack (isolation, firewall ordering, reset/destroy cleanup, audit events). Some properties (idle reaper timing, remote TLS lab-host, reconciliation-on-crash, real-image k3s behavior) were validated manually during implementation rather than automated — see the Phase 1 final report for what and why.
9. Platform API (Phase 2B)
Full design: docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md. Operational reference:
platform/api/README.md.
- What it is:
platform/api/— a lightweight Node/Express service (no k3s, no--privileged, no Lab terminal runtime) owning students, cohorts, enrollments, curriculum assignments, progress, scenario attempts, and — as of Phase 3A — instructor accounts, persistent sessions, and the audit log (§10). The sole caller of the Provisioner for both the student launch/reset path and the instructor/admin control plane. - What it is not: reachable directly by a browser — always through the Gateway
(
platform/gateway), same trust boundary the Provisioner has relative to this service. - Local dev: part of the same
platform/docker-compose.dev.ymlstack as the Provisioner — no separate compose file. - Test procedure:
platform/test/e2e-phase2a.js(student flow) andplatform/test/e2e-phase2b.js(classroom acceptance workflow) — both need a bootstrapped instructor account as of Phase 3A, see §10.
10. Identity and instructor access (Phase 3A)
Full design: docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md. Operational reference:
platform/api/README.md (bootstrap, config).
- What it is: persistent Postgres-backed sessions (student + instructor, survive a Platform
API restart), real per-instructor accounts (
owner/admin/instructorroles, scrypt password hashing, per-instructor salt), server-enforced RBAC on every/api/admin/*route, cohort-scoped access for the plaininstructorrole, CSRF double-submit protection, login rate limiting, and a platform-levelaudit_eventslog. - What it replaced: Phase 2B's in-memory session
Maps (gone on every restart) and the single sharedADMIN_PASSWORDenv var (no accountability, no roles, no revocation). - Bootstrap:
node platform/api/bootstrap-owner.js <email> <name> <password>— the only supported way to create the first instructor account; no manual database editing. - Test procedure:
platform/test/e2e-phase3a.js— 21 automated tests covering session persistence (including a realdocker compose restart api), instructor auth, RBAC, cohort 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.