POST /api/student/activities/:activityId/complete previously checked only that the activity
exists and the student holds an active PROGRAM enrollment — never whether the activity's own
course/module was actually unlocked for that student. A direct POST could mark any
manual_marker activity (lesson/demonstration/knowledge_check/guided_exercise/reflection)
complete regardless of the real prerequisite chain, the exact class of gap Phase 6B already
fixed once for the capstone's own start route, just never closed for this more general one.
Found while proving the real ROOT 01->02->03->04->05 chain via direct API (this phase's own
explicit requirement) — and confirmed to predate Phase 6E entirely by reproducing it against an
unrelated, pre-existing ROOT 05/06 pair before touching any Phase 6E content.
Fix: the route now resolves the activity's own course/module through the exact same
buildCourseTree() lock computation every other route already trusts, and returns 403 { error:
'locked' } if either is locked — reusing the existing mechanism, not a second, duplicated check.
Verified live: both a ROOT 02 bypass attempt and a ROOT 06 bypass attempt are now correctly
rejected; legitimate unlocked completions are unaffected.
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).