- classroom.js: create/start/end/cancel (DB-level partial unique index guarantees at most one live classroom per cohort; app layer catches 23505 and returns a clean ALREADY_LIVE), join/ leave/heartbeat, derivePresence (pure, derived from last_seen_at at read time — no stored status to go stale), help-request create/acknowledge/resolve, announcements, current-activity (guidance only, never redirects a browser or marks work complete), append-only event log. - admin-classroom-routes.js: instructor-facing classroom API, RBAC via rbac.js's requireClassroomAccess/requireClassroomWriteAccess/requireHelpRequestWriteAccess (resolves through to the classroom's cohort, same owner/admin/instructor/viewer rules as everywhere else cohort-scoped). The dashboard route (GET /api/admin/classroom-sessions/:id) combines real Provisioner Lab state and real academic records with classroom.js's own data — no second Lab-state or academic-state system. - classroom-routes.js: student-facing API (live classroom, join/leave/heartbeat, help-request, announcements), requireEnrolledClassroom middleware. - prepare-labs.js: shared prepareLabsForCohort, extracted so the existing cohort-level and new classroom-level Prepare Labs routes share one implementation; maps Phase 4.1 admission control's CAPACITY_EXCEEDED into a capacity_waiting per-student result.
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,
docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md for persistent sessions, per-instructor
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
| Variable | Required | Meaning |
|---|---|---|
PLATFORM_DB_URL |
no (defaults to local dev Postgres) | Postgres connection string |
PROVISIONER_URL |
no | Provisioner base URL |
PROVISIONER_SHARED_SECRET |
yes | Internal Provisioner auth — process refuses to start without it |
PLATFORM_API_PORT |
no | Listen port, default 4300 |
FRONTEND_DIST_DIR |
no | Where the built frontend lives — set by the Dockerfile, override for local non-Docker dev |
SCENARIOS_DIR / BUNDLES_DIR / CURRICULUM_PATH_FILE |
no | Override the default scenarios/ locations, mainly for local dev outside Docker |
STUDENT_SESSION_TTL_HOURS / STUDENT_SESSION_IDLE_HOURS |
no | Default 8h / 2h |
INSTRUCTOR_SESSION_TTL_HOURS / INSTRUCTOR_SESSION_IDLE_HOURS |
no | Default 12h / 2h |
COOKIE_SECURE |
no | Set to 1 once served over real HTTPS — adds Secure to every session/CSRF cookie |
TRUST_PROXY |
no | Set to 1 once fronted by a trusted reverse proxy — honors X-Forwarded-For for req.ip (audit source_ip, login rate limiting) |
There is no ADMIN_PASSWORD anymore — instructor accounts live in Postgres (instructors
table), created via bootstrap-owner.js for the first one and the admin portal after that.
Local dev
cd platform
echo "PROVISIONER_SHARED_SECRET=$(openssl rand -hex 32)" > .env
docker compose -f docker-compose.dev.yml build
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"
Everything is reachable through the Gateway at http://localhost:4200 — students at /,
instructors at /admin (sign in with the bootstrapped owner). Platform API itself isn't meant
to be hit directly by a browser (same boundary as the Provisioner relative to Platform API),
though nothing stops you from curling it directly at :4300 for local debugging.
Owner bootstrap
node bootstrap-owner.js <email> <display_name> [password]
Prefer BOOTSTRAP_OWNER_PASSWORD over the positional argument outside a throwaway local test —
CLI args can leak into shell history and ps. Refuses to run if an active owner already exists
unless the target email belongs to an existing instructor (the intended recovery path if a
platform is ever locked out with zero owners: point this at a known instructor account to
promote them).
SQLite migration
One-time cutover, run against the real data volume:
docker run --rm -v <identity-data-volume>:/data \
-e SQLITE_PATH=/data/progress.db -e PLATFORM_DB_URL=postgres://postgres:postgres@<host>:5433/rootnode_platform \
<platform-api-image> node migrate-sqlite.js
Always backs up the SQLite file first, is safe to re-run, and writes a JSON report — see
docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md §2/§3 for the full guarantees and rollback
procedure. This step is about academic data (students/cohorts/progress) — it has nothing to do
with instructor accounts, which are Phase 3A-native and start empty until you bootstrap one.
Tests
platform/test/e2e-phase2a.js (student-flow regression), platform/test/e2e-phase2b.js (the
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 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).