- TypeScript 97%
- CSS 2.3%
- Shell 0.4%
- JavaScript 0.2%
- HTML 0.1%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
|
||
| .forgejo/workflows | ||
| .githooks | ||
| apps | ||
| deploy | ||
| docker | ||
| docs | ||
| e2e | ||
| packages | ||
| scripts | ||
| .dockerignore | ||
| .editorconfig | ||
| .gitignore | ||
| .nvmrc | ||
| .prettierignore | ||
| .prettierrc.json | ||
| CLAUDE.md | ||
| eslint.config.js | ||
| package.json | ||
| pnpm-lock.yaml | ||
| pnpm-workspace.yaml | ||
| README.md | ||
| tsconfig.base.json | ||
| tsconfig.json | ||
| vitest.config.ts | ||
Aivopod
Aivopod is a learning platform for programming lessons at school. Students work through small, fine-grained exercises ("challenges") in JavaScript: reading code, predicting output, marking parts of code, sorting, running, changing and writing code. Each challenge has three levels (normal, erweitert, experte). Teachers get a minimal view of how the class is doing and CSV exports for their own analysis in Excel.
What sets it apart:
- No accounts for students. They join with a printed card (code and QR code). Only a pseudonym is visible to other students.
- Student code never runs on the server. It runs in a sandboxed iframe on its own origin, inside a Web Worker with a time limit, without network access or cookies.
- Nothing is lost. Code is saved continuously through an offline outbox in IndexedDB. It reaches the server after a lost connection and comes back on another device.
- Content lives in challenge packs: separate repositories written with a small TypeScript SDK (
defineChallenge, step kinds, declarative checkers), tested like code and published with the CLI. - Teachers sign in via OIDC with the school's identity provider. Administration (courses, students, cards, assignments) is done with the
aivopodCLI. - Everything is translatable (i18next with ICU messages). German is the first language.
The binding specification is docs/SPEC.md (German), decisions are in docs/decisions.md, and the remaining differences from the plan are in docs/deviations.md. Operations are described in docs/betrieb.md (German).
Infrastructure
Aivopod runs as a Docker Compose stack behind Caddy on one server, with three origins:
| Origin | Serves | Notes |
|---|---|---|
app.<domain> |
Web app (React SPA) and the API under /api |
Strict CSP; session cookies HttpOnly; Secure; SameSite |
run.<domain> |
Runner frame (sandboxed iframe) | connect-src 'none', no cookies; runs student code in Blob workers |
assets.<domain> |
Pack bundles and images, addressed by SHA-256 | Content-Security-Policy: sandbox, immutable caching |
| Compose service | Role |
|---|---|
caddy |
TLS (Let's Encrypt or a local CA), the three hostnames, security headers, static files of the web app and runner frame |
api |
Fastify API (Node 26, TypeScript) |
worker |
pg-boss worker verify: decides submissions with the configured runner (RUNNER=client in v0) |
postgres |
PostgreSQL 16; every table with student data carries school_id and is protected by row-level security |
db-init |
One-off: creates roles and database, runs migrations and sets up the job-queue schema |
backup |
Nightly pg_dump plus the asset volume, encrypted with age, optionally copied off-site with rsync |
Stack: TypeScript (strict), pnpm workspace, React + Vite + React Router, CodeMirror 6, @lezer/javascript, Fastify, valibot (shared schemas), PostgreSQL 16 with Kysely, pg-boss, i18next (ICU), openid-client, marked + DOMPurify, lucide, sharp, Vitest and Playwright.
Repository layout
apps/
api/ Fastify API, migrations, operator commands (dist/ops.js), worker (dist/worker.js)
web/ React SPA: student view, teacher view (/teacher), join page
runner-frame/ Sandboxed runner: loads pack bundles hash-checked, runs code and checkers
cli/ The `aivopod` command-line tool for teachers and pack authors
packages/
shared/ valibot schemas for the API and the postMessage protocol, route registry
sdk/ Pack authoring SDK (defineChallenge, step kinds) and test helpers
checkers/ Checker blocks, named error patterns, feedback IDs
harness/ Runs code with limits (Node and browser) and applies the checkers
analysis/ lezer-based code analysis (nodes, ranges, node-level diff)
i18n/ i18next setup, catalogs (locales/de), formatting, CSV writer
deploy/ Production compose.yml, .env.example, caddy/Caddyfile, MIGRATION_NOTES.md (the deploy bundle)
docker/ Dockerfile, compose.dev.yml, compose.e2e.yml (test IdP), backup scripts
scripts/release/ Image, bundle and upload scripts of the publish workflow (docs/ci.md)
e2e/ Playwright tests against the running stack, incl. the lesson scenario
docs/ SPEC, decisions, deviations, operations manual
e2e/packs/ Pack repositories checked out for the E2E tests (git-ignored)
Requirements
- Node.js 26 (
.nvmrc) and pnpm 12 (npm install -g corepack && corepack enable; Node 26 no longer ships corepack) - Docker with Compose v2 (for the stack, the database tests and the end-to-end tests)
- For browser tests: Chromium from Playwright (
pnpm exec playwright install --with-deps chromium)
Build, test and run
Workspace commands
| Command | What it does |
|---|---|
pnpm install |
Install all dependencies |
pnpm build |
Build every app and package |
pnpm test |
Unit and integration tests (Vitest); the API tests start PostgreSQL with Testcontainers |
pnpm test:browser |
Browser tests in Chromium (harness workers, web components) |
pnpm lint |
ESLint |
pnpm format:check |
Prettier check |
pnpm typecheck |
Type check, including tests |
pnpm check |
All three: format, lint, types |
pnpm format |
Format everything with Prettier |
pnpm test:stack |
Header tests (CSP, cookies, CORS) against the running stack |
pnpm test:e2e |
Playwright tests against the running stack |
pnpm --filter @aivopod/api db:codegen |
Regenerate the Kysely types from the migrated database |
If Chromium's system libraries are missing locally, scripts/playwright-server.sh starts Chromium in the official Playwright image. Point PLAYWRIGHT_WS_ENDPOINT at it.
Run locally
The production compose file with images built from the checkout (docker/compose.dev.yml); it also mounts the built SPA and runner frame, so pnpm build shows up without a new image:
cp deploy/.env.example deploy/.env # *.localhost, TLS=internal, HTTPS on port 8443
docker compose -f deploy/compose.yml -f docker/compose.dev.yml up -d --build --wait
The web app is then at https://app.localhost:8443 (Caddy's local CA; accept the certificate or trust /data/caddy/pki/authorities/local/root.crt from the caddy container). Stop with down, and add -v to also delete all data.
For teacher login without a real identity provider, use the E2E override with the test IdP instead (development and tests only):
docker compose -f deploy/compose.yml -f docker/compose.e2e.yml up -d --build --wait
End-to-end tests
docker compose -f deploy/compose.yml -f docker/compose.e2e.yml down -v # the lesson needs a fresh stack
docker compose -f deploy/compose.yml -f docker/compose.e2e.yml up -d --build --wait
pnpm build && pnpm test:stack && pnpm test:e2e
The lesson scenario (e2e/tests/lesson.spec.ts) plays a whole lesson:
- A teacher signs in through OIDC, sets up a course with the CLI, prints cards and assigns the example pack.
- Three students join with the QR codes from the card PDF. One completes a level, one loses the connection while writing, and one clears the browser storage and continues on a "new device".
- The teacher checks the overview and exports a CSV.
It needs the Grundlagen pack repository in e2e/packs/grundlagen (or E2E_PACK_DIR) with its dependencies installed (npm ci), and the compiled CLI (AIVOPOD_CLI, default aivopod-cli/dist/aivopod).
CI runs the same in .forgejo/workflows/check.yml and e2e.yml, see docs/ci.md. pnpm install activates a pre-push hook (.githooks/pre-push) that runs the format check and ESLint.
Deploy
The server builds nothing. It runs the deploy bundle of a release (aivopod-deploy-<version>.zip from the generic package registry: compose.yml, .env.example, caddy/Caddyfile, release-trust.json, VERSION, MIGRATION_NOTES.md) with images pulled by digest, managed with apcmd (SPEC section 11). Short version (details in docs/betrieb.md, German):
- Point DNS for the four host names (app, run, assets and the preview domain, which must be its own registrable domain) to the server, with ports 80 and 443 open.
- Install
apcmd:curl -fsSL https://git.belowtoxic.cloud/api/packages/aivopod/generic/apcmd/latest/install.sh | sh sudo apcmd install --app-host … --preview-host … --tls <acme e-mail> --oidc-issuer … --oidc-client-id … --oidc-client-secret …loads and verifies the bundle, writes.envwith generated secrets, pulls the images, starts the stack and checks its health. Copy the generated backup key (backup-identity.key) and a copy of.envoff the server.sudo apcmd school create --name … --sub <oidc sub> --teacher-name …creates the school and its first teacher, who then signs in athttps://<app host>/teacher.
Updating: sudo apcmd update takes a backup, verifies the new bundle, shows MIGRATION_NOTES.md, adds new variables to .env, pulls the images and restarts (db-init applies new migrations; they only go forward). If the new version does not come up healthy, it returns to the previous one; apcmd rollback [--restore-db --identity <key>] does so by hand.
Management commands
Operator commands (in the API image)
dist/ops.js in the API image holds every command that touches the database, so it always matches the installed version. apcmd calls it; by hand, in the installation directory:
docker compose run --rm ops node dist/ops.js <command> [--json] [--locale de]
| Command | What it does |
|---|---|
bootstrap |
Create the roles migrator and app and the database (idempotent; db-init does this on start) |
migrate |
Apply pending migrations and set up the job-queue schema (db-init does this on start) |
create-school --name <name> [--school-locale de] [--issuer <iss> --sub <sub> --teacher-name <name>] |
Create a school (idempotent by name); optionally bind a known login as owner |
list-schools |
Schools with their teachers (IDs) and open claim links |
add-teacher --school <id|name> [--teacher-name <name>] [--valid-hours 72] |
One-time claim link for a new teacher of the school (D075) |
link-teacher --teacher <id> [--school <id|name>] [--valid-hours 72] |
One-time link that moves a teacher to another login |
health |
Database, migrations, job queue, asset volume (with unpublished uploads) and API |
restore-prepare --confirm <database> |
Drop the database and create it empty again, before restoring a backup |
cleanup [--hours 24] |
Remove uploads that were never published |
version |
Version of the image |
Output is a message from the ops catalog (German by default); with --json, one object {ok, id, params, message, data}. Exit code 0 on success, 1 on failure, 2 on wrong usage.
Backup and restore
| Command | What it does |
|---|---|
apcmd backup now |
Take a backup now (database and assets, encrypted with age) |
apcmd backup list |
List the backups in backups/ of the installation directory |
apcmd backup restore <file>.dump.age --identity <key> |
Restore database and assets, migrate, start, check health |
The restore procedure, also for an empty system, is in docs/betrieb.md, section 6.
Teacher CLI: aivopod
A single executable without Node or Deno, from its own repository aivopod/aivopod-cli:
curl -fsSL https://git.belowtoxic.cloud/api/packages/aivopod/generic/aivopod-cli/latest/install.sh | sh # Linux, macOS
irm https://git.belowtoxic.cloud/api/packages/aivopod/generic/aivopod-cli/latest/install.ps1 | iex # Windows
aivopod login --url https://app.<domain> signs in with a token from https://app.<domain>/cli-token, or with --device through the IdP's device flow. Messages come from the platform in the teacher's language; aivopod help lists all commands: administration (school, year, course, teacher, students import, students list, student add, student delete, codes issue, cards pdf, assign, reorder, unlock, export), profile for several platforms, self-update, and the pack tools below. Every command takes --json.
Pack authors
Packs live in their own repositories (aivopod pack init <name> creates one). In a pack directory, or with --dir <path>:
| Command | What it does |
|---|---|
aivopod pack build |
Bundle the pack and check assets, icons, image references, technik and step IDs |
aivopod pack test |
Run the pack's tests (expectPass / expectFail from @aivopod/sdk/test), without vitest |
aivopod pack preview [--port] |
Play the pack in the browser with the platform's real step components; nothing is stored |
aivopod pack publish |
Build, test and publish (refuses failing tests, an existing version, an SDK newer than the platform) |
The author handbook (German) is in the CLI repository: docs/handbuch.md.
In the teacher view (/teacher), teachers see the course overview, each student's history with code diffs, figures per assignment, and CSV downloads.