- 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]>
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.
Cross-site requests and the session cookie
- Every state-changing request (POST, PUT, PATCH, DELETE), sign-in included, must carry an
Origin(or, without one, aReferer) equal toSTC_PUBLIC_URL's origin or one inSTC_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 inSTC_ALLOWED_ORIGINS. - A write with a body must be
application/json(uploads:multipart/form-data). HTML forms andtext/plainbodies, 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), notStrict. 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. UnderStrictthe browser withholds it, and social, OIDC and SAML sign-in break. The Origin check above covers the same-site cases thatLaxallows (other ports on the same host, sibling subdomains).
Security headers
- The web app carries its Content-Security-Policy as a
<meta>tag inapps/web/index.html(no inline script). A meta tag cannot setframe-ancestors; if you need clickjacking protection, addContent-Security-Policy: frame-ancestors 'self'(orX-Frame-Options: SAMEORIGIN) in your reverse proxy. - Course media (
/api/media/…) is sent withX-Content-Type-Options: nosniff, and SVG, HTML and JavaScript files withContent-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.