POST /api/assessments/:definitionId/start only ever checked that the
definition existed and was active — it never checked unlock_rule. GET
/api/capstone correctly computed and displayed lock state, and the
student UI never exposed a start action while locked, but a direct POST
to this route bypassed the check entirely (documented as a known gap in
docs/PHASE6A_ROOT_COURSE_FOUNDATION.md §10). Student UI state is not the
enforcement boundary.
Fixed by binding to the exact same computeCurriculumState() gate
/api/capstone already relies on, rather than re-parsing
definition.unlock_rule independently — one computation of 'is this
unlocked,' not two that could drift. Only a definition the curriculum
path actually names as ITS capstone is gated this way; an ordinary exam
is completely unaffected. Returns a clean 403 { error: 'locked',
blocking_stage_ids } — the same shape /api/capstone already exposes.
Found while fixing this: e2e-phase5b.js's own capstone-completion test
had been passing only because of this exact gap — it never included the
real-ops stage in its completion loop before attempting to start the
capstone, and the missing unlock check let it succeed anyway. Fixed
alongside the platform code (REAL_OPS added to ALL_NON_CAPSTONE), not
routed around.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
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).