Three roles (owner > admin > instructor), enforced server-side on every /api/admin/* route via reusable middleware (requireOwner/requireAdminOrOwner/requireAnyInstructor) — the frontend hides controls a role can't use, but that's UX, never the actual boundary. A plain 'instructor' is further scoped to cohorts assigned via the new cohort_instructors table (requireCohortAccess/requireStudentCohortAccess); owner/admin bypass that check entirely. Owner-only: instructor account lifecycle, audit log. Admin/owner: cohort/student CRUD, curriculum assignment. Any instructor role (cohort-scoped for 'instructor'): dashboard, cohort/student views, Prepare/Destroy Labs, provisioning. Deliberately NOT cohort-scoped: the raw /api/admin/environments/:id/* routes (no cohort context in a bare environment id without an extra Provisioner round trip on every call) — a plain instructor still has full Lab control via the cohort/student-scoped routes instead, which already carry the check. server.js wires the new persistent-session middleware (studentSession/instructorSession — re-fetches the instructor row from Postgres every request, so a role change or deactivation takes effect on the very next request with no separate revoke needed) and adds a last-resort error handler so a DB error in any async route returns 500 instead of crashing the process or leaking a stack trace.
RootNode Academy — Platform API (Phase 2B)
Lightweight service: no k3s, no --privileged, no Lab terminal runtime. See
docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md for the design; this 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 |
ADMIN_PASSWORD |
yes | Instructor login credential — process refuses to start without it. Verified via scrypt+timing-safe compare, never stored or logged in plaintext |
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 |
Local dev
cd platform
echo "PROVISIONER_SHARED_SECRET=$(openssl rand -hex 32)" > .env
echo "ADMIN_PASSWORD=$(openssl rand -hex 12)" >> .env
docker compose -f docker-compose.dev.yml build
docker compose -f docker-compose.dev.yml up -d
Everything is reachable through the Gateway at http://localhost:4200 — students at /,
instructors at /admin. 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.
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.
Tests
platform/test/e2e-phase2a.js (student-flow regression, cookie-based) and
platform/test/e2e-phase2b.js (the classroom acceptance workflow: cohort → students →
curriculum → Prepare Labs → complete scenarios → reset → destroy-labs → progress survives).
Both need the full stack up and ADMIN_PASSWORD set — see platform/README.md.
Known limitations
See docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md §13 — in-memory sessions (a restart logs
everyone out), exam mode's live bookkeeping stays Lab-local, single shared admin credential
rather than per-instructor accounts.