- 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]>
101 lines
6.6 KiB
Markdown
101 lines
6.6 KiB
Markdown
# 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
|
|
|
|
```sh
|
|
# 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, 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.
|