In-browser HTML/CSS/JS editor for Code Camp. Self-hosted: Go API + Vite/React web + Postgres.
- Web: Vite + React + TypeScript + Tailwind + CodeMirror 6 (built and embedded into the Go binary for prod)
- API: Go (chi), Postgres via pgx — Postgres is required (no in-memory fallback)
- DB: Postgres; lessons, assets, users, work, sessions, and locks all live here
- Lessons: a
catalog.jsonmanifest + files. A neutral sample course ships embedded atapi/internal/seed/sourceand is seeded into the DB on first boot (app seed). Bring your own course by pointingSEED_SOURCE_DIRat a directory with the same layout — no rebuild needed. - Deploy: single image, runs anywhere Postgres is reachable (Docker Compose, plain k8s/k3s)
Build and run the whole stack — app + Postgres — with one command:
ADMIN_EMAILS=you@example.com docker compose up --build
The app migrates the DB and seeds the embedded sample course on first boot, then
serves on http://localhost:8080. Sign in with the email you set in
ADMIN_EMAILS to get the instructor view; any other email registers as a camper.
Run Postgres in Docker and the API + web dev server on the host (three terminals, or background the first two).
DB only:
docker compose up -d db
API (Postgres required; first run self-seeds the sample course):
cd api
go mod tidy
DATABASE_URL='postgres://codecamp:codecamp@localhost:5432/codecamp?sslmode=disable' \
ADMIN_EMAILS='you@example.com' \
go run ./cmd/api
Listens on :8080 (matches the Vite proxy). Subcommands: go run ./cmd/api migrate
(migrations only) and go run ./cmd/api seed (migrate + load lessons/assets). On a
fresh DB, plain serve auto-seeds when the lessons table is empty.
Web:
cd web
npm install
npm run dev
Vite dev server on :5173, proxies /api to :8080. (In prod the Go binary serves
the built web itself; Vite is dev-only.)
The embedded sample course lives at api/internal/seed/source — a catalog.json
manifest plus the HTML/CSS/JS/asset files it references. To run your own lessons,
copy that layout into a directory and point the app at it:
SEED_SOURCE_DIR=/path/to/your/course go run ./cmd/api seed
Each catalog entry maps a source file to a lesson and may apply optional
transforms: variant: "sectioned" + step: N keeps the first N of
<header>/<section class="hero">/<section class="cards-section">/<footer>
for cumulative build-up lessons, and break: {find, replace} injects a deliberate
bug for fix-it lessons. No rebuild is needed — the source is read at seed time.
Email-first flow: /api/auth/check branches the client to password login,
first-time password setup (legacy email-only accounts), or registration.
Passwords are bcrypt-hashed; sessions are DB-backed bearer tokens.
SSO (optional): when OIDC_CLIENT_ID is set, the sign-in screen also offers
a "Sign in with OIDC_PROVIDER_NAME" button. The Authorization Code flow runs
server-side (/api/oidc/login → provider → /auth/callback), the id_token is
verified, and the same DB session is issued; the token returns to the SPA in the
URL fragment. SSO accounts whose email is in ADMIN_EMAILS become instructors.
Campers without an SSO account keep using email + password. Forgot a password?
An instructor clears it from the Admin → Campers roster ("reset password"); the
account becomes passwordless, so the camper picks a new one on their next
sign-in (the email-first flow routes them through set-password). Out of band,
the CLI still works: go run ./cmd/api set-password <email> <password>.
Instructors (role='instructor', via ADMIN_EMAILS) get the admin panel at
?admin=1. Two tabs:
- Lessons — lock/unlock per-lesson or per-day. Locked lessons are disabled in the camper picker and 403 on open; saved camper work is never deleted by locking.
- Campers — roster of every camper and the lesson work they've saved. Open
a project to see it run live and remotely fix it (
save fixoverwrites that camper's copy for the lesson).
Admin routes live under /api/admin/* behind RequireInstructor. The legacy
unscoped pen CRUD (/api/pens) is instructor-only too, since it lists every
camper's work; campers use the (user, lesson)-scoped routes under
/api/users/{id}/pens/{lesson}.
Lesson content is pre-rendered at seed time: the seeder runs the catalog
transforms (step truncation, deliberate fix-it bugs, self-ref stripping) once
and stores the final HTML/CSS/JS per slug, so the runtime just reads rows.
Binary assets (images) are stored as bytea and served publicly from
/api/assets/<module>/<path> with long-lived cache headers — the sandboxed
preview iframe loads them. The IDE shows them in a read-only assets/ file
tree (GET /api/lessons/{slug}/assets).
The whole app is one image — the Go binary serves both the embedded web app
and /api. It runs anywhere it can reach a Postgres database.
- Build the image (build context is the repo root):
docker build -t codecamp-ide . - Run a Postgres instance and give the app
DATABASE_URL. - Set
ADMIN_EMAILSto your instructor address(es). Inject any OIDC secrets from your platform's secret manager. - Serve it over HTTPS behind your ingress/reverse proxy of choice.
The app migrates and seeds on first boot. To run migrations as a separate
pre-deploy step instead, run app migrate (and optionally app seed) and set
RUN_MIGRATIONS=false on the serving pods.
Pre-built multi-arch images are published to
ghcr.io/reusserdesign/codecamp-ide (tags: latest, vX.Y.Z, branch, sha).
A chart lives in deploy/helm:
helm install codecamp ./deploy/helm \
--set adminEmails=you@example.com \
--set database.existingSecret=codecamp-db
Bring your own Postgres. A pre-install Job migrates + seeds before the pods roll.
Run your own (private) curriculum on the public image by pointing course.gitRepo
at a repo with a catalog.json — an initContainer clones it and the seeder reads
it via SEED_SOURCE_DIR, so no custom image build is needed. See the chart README
for ingress, OIDC, and the bring-your-own-course options.
cd api && go test ./...
Preview/share iframes run user JS with sandbox="allow-scripts" and no allow-same-origin, so they cannot read parent cookies or storage. Do not add allow-same-origin. The authoring Preview also gets allow-modals (so a camper's own alert/confirm/prompt works) — but ShareView deliberately omits it, since a shared pen is another user's code on our origin and modal dialogs could be used to phish the viewer. Session tokens live in the parent app's localStorage only — the iframe can't reach them.
