Files
STCourses/docs/operations.md
T
Lucas WintherandClaude Opus 5.5 8045c80d93 fix: final-review wave (CSRF guard, fail-closed owner bootstrap, streamed zips, e2e visibility suite)
- Critical 1 (CSRF): core wrap refuses every POST/PUT/PATCH/DELETE whose Origin
  (Referer as fallback) is not publicUrl's origin or STC_ALLOWED_ORIGINS, and
  any body that is not application/json (multipart only on declared upload
  routes). Better Auth routes go through the same guard; only declared
  identity-provider callbacks (SAML ACS/SLO, OAuth callbacks) are exempt.
  Better Auth trustedOrigins use the same list. Session cookie stays Lax
  (Strict breaks OAuth/OIDC/SAML returns); documented.
- Important 4 / learner minor 4: the first-owner claim and the role row are
  one transaction guarded by "no owner yet"; only a unique violation means
  learner; any other error fails the sign-up with 500 and removes the
  half-created user. Serialised per DB handle (Bun's SQLite connection).
- Important 2 / learner minor 12: code zips stream entry by entry (large
  files stored in 256 KB chunks with a data descriptor and incremental
  CRC-32), so memory stays at one chunk; the cap is now 1 GiB (ZIP32).
- Important 3 / learner minor 10: the e2e runs the production wiring
  (productFeatures order, AI drafts through the real sink) against a
  restricted course and asserts 404/401 for learner and anonymous on
  course, lesson, media, cover, code, zip, notes, progress, attempts, AI
  chat, library, search and AI status; a real Range request (206, 416);
  forged writes (foreign Origin, text/plain, no Origin) are 403 and the MCP
  test command never runs.
- Minors 7-9: compose passes every documented variable; the health check
  uses STC_PORT; productFeatures checks the studio-before-AI order.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 15:42:17 +01:00

6.6 KiB

Operating STCourses

One server process (Bun) serves the API and the web app. Course content lives in folders; the database holds user data only (accounts, progress, notes, attempts, settings), so deleting the database loses user data but never a course.

Run with Docker Compose

# SQLite in the data volume (the default)
STC_SECRET="$(openssl rand -base64 48)" docker compose up -d

# Postgres instead
STC_SECRET="$(openssl rand -base64 48)" \
STC_DATABASE_URL=postgres://stc:stc@postgres:5432/stc \
  docker compose --profile postgres up -d

The first account that signs up becomes the owner. Open http://localhost:3000 (or STC_HOST_PORT).

Mounts (all course mounts are read-only; STCourses never changes your originals):

Host Container What
volume stc-data /data database (SQLite), imported courses (/data/courses), studio repos (/data/studio/repos) and their published worktrees (/data/worktrees), caches (/data/cache), AI drafts for non-studio courses (/data/drafts)
STC_COURSES_HOST (default ./courses) /courses course repos, one folder with a stcourse.yaml per course
STC_IMPORT_HOST (default ./import-inbox) /import files the owner may import (Packt zips, video folders, PDFs, code zips)

An overlay in overlays/ (baked into the image) names its course repo by absolute path in root:. Mount that repo read-only at the same path inside the container, for example /home/me/functional-programming-course:/home/me/functional-programming-course:ro. If the repo is missing, the course shows as broken with a diagnostic and every other course keeps working.

Back up the stc-data volume (and the Postgres volume stc-pg when you use Postgres).

Configuration

Variable Default Meaning
STC_SECRET none (required in production) encrypts users' own AI keys and signs sessions; use 32+ random characters
STC_PUBLIC_URL http://localhost:<port> the URL users open; sign-in checks the request origin against it
STC_PORT 3000 listen port
STC_DATABASE_URL sqlite://./data/stcourses.db (image: sqlite:///data/stcourses.db) sqlite://<path> or postgres://…
STC_DATA_DIR ./data (image: /data) see the table above
STC_COURSE_DIRS ./overlays:./courses (image: /app/overlays:/courses) :-separated folders scanned for courses; <data>/courses and <data>/worktrees are always added
STC_IMPORT_ROOTS <data>/import-inbox (image: /import) :-separated folders the importer may read
STC_UPLOAD_MAX_MB 100 studio upload cap
STC_PYTHON python3 Python with PyMuPDF, for PDF page images and text (installed in the image)
STC_TRANSCRIBE_URL, STC_TRANSCRIBE_KEY, STC_TRANSCRIBE_MODEL unset Whisper-compatible endpoint for video transcripts; unset disables transcription
STC_AI_ALLOWED_BASE_URLS unset ,-separated origins learners' own AI keys may use besides api.anthropic.com and api.openai.com
STC_AUTH_<GITHUB/GOOGLE/MICROSOFT/GITLAB/DISCORD>_ID, _SECRET unset social sign-in
STC_AUTH_OIDC_ISSUER, _ID, _SECRET, _NAME, _SCOPES, _DISCOVERY_URL, STC_AUTH_GROUPS_CLAIM unset generic OIDC sign-in; group claims drive restricted visibility
STC_ALLOWED_ORIGINS unset extra ,-separated origins (e.g. http://192.168.1.5:3000) allowed to send writes and sign in, besides STC_PUBLIC_URL's origin. STC_TRUSTED_ORIGINS is read as an alias

Without Docker: bun install, then STC_SECRET=… NODE_ENV=production bun apps/server/src/main.ts. The importer needs ffmpeg, ffprobe, git, unzip and Python with PyMuPDF on the host.

  • Every state-changing request (POST, PUT, PATCH, DELETE), sign-in included, must carry an Origin (or, without one, a Referer) equal to STC_PUBLIC_URL's origin or one in STC_ALLOWED_ORIGINS. Anything else gets 403. Open STCourses at exactly that URL. If people reach it by several names (a LAN IP and a hostname), list the others in STC_ALLOWED_ORIGINS.
  • A write with a body must be application/json (uploads: multipart/form-data). HTML forms and text/plain bodies, which another page can send without a CORS preflight, are refused.
  • Exempt: the identity-provider callbacks (SAML ACS and SLO, OAuth/OIDC callbacks under /api/auth). The provider posts those from its own origin, and Better Auth verifies the signed assertion or the OAuth state itself.
  • The session cookie stays SameSite=Lax (Better Auth's default), not Strict. OAuth/OIDC sign-in returns from the provider by a cross-site top-level navigation, and Better Auth needs its state cookie on that request. Under Strict the browser withholds it, and social, OIDC and SAML sign-in break. The Origin check above covers the same-site cases that Lax allows (other ports on the same host, sibling subdomains).

Security headers

  • The web app carries its Content-Security-Policy as a <meta> tag in apps/web/index.html (no inline script). A meta tag cannot set frame-ancestors; if you need clickjacking protection, add Content-Security-Policy: frame-ancestors 'self' (or X-Frame-Options: SAMEORIGIN) in your reverse proxy.
  • Course media (/api/media/…) is sent with X-Content-Type-Options: nosniff, and SVG, HTML and JavaScript files with Content-Security-Policy: sandbox, so author content cannot run script on the app's origin.

Memory budget

The deployment must stay under 2 GB idle. Measured with this compose file (--profile postgres), the image built from this repository, and four courses mounted read-only: the sample course, Pure Snake and Systemic Design (both overlays, repos mounted at their own paths), and the imported Packt course "Mastering Docker Essentials" (68 videos, 9 modules, a 59-slide deck, code files; about 800 MB of media). Before idling, one owner signed up and opened the library, a video lesson with its deck, a Pure Snake lesson and the sample exercise in headless Chrome. Then nothing for 5 minutes, and docker stats --no-stream:

Container Memory (idle, after 5 min) CPU
stc-app-1 (Bun server, SPA bundle built and cached) 84.7 MiB 1.00%
stc-postgres-1 (postgres:17) 16.67 MiB 3.52%
Total 101.4 MiB (about 5% of the 2 GB budget)

Measured 2026-10-30 on Linux 6.12 (Docker Compose v5.5.1, oven/bun:1.3 = Bun 1.3.14). With SQLite there is no Postgres container, so the total is the app's figure alone.

Video and PDF files are streamed from disk with Range requests; they never sit in memory. The SPA bundle is built once, on the first page request, and cached.