Skip to content

Repository layout

One pnpm workspace, TypeScript end to end. Four applications under apps/, the shared code under packages/, and everything else at the root. The schema, the module boundaries and the real-time design are settled in the architecture chapter of the specification; this page tells you where to find things and which rules a change must respect.

The map

apps/
  api/        Fastify: modules, SSE, jobs, ticker, Drizzle schema + migrations
  web/        React SPA (Vite, Tailwind, TanStack Query)
  runner/     code execution in hardened Podman containers (@quiz/runner)
  codespace/  the online workspace portal: code-server per student, SEB exam
              mode, SQLite (@quiz/codespace, its own CLAUDE.md)
packages/
  core/       the QuestionType contract, the seeded RNG, the runner interface
  registry/   the two static question-type registries (./server, ./client)
  contracts/  zod schemas and payload types shared api <-> web
  domain/     pure business rules: grade scale, deadlines, policies, cloze, roster
  docrender/  the server-side journal renderer
  seb/        Safe Exam Browser, pure: typed plist, Config Key, request hashes
  ui/         the shared primitives of the question-type surfaces (@quiz/ui)
  qt-mcq/     question type: multiple choice
  qt-short/   question type: short answer
  qt-cloze/   question type: fill in the blanks
  qt-code/    question types: code graded by the runner, and its variant
              codeimage graded pixel by pixel
  qt-circuit/ question type: two-port schematic, graded by ngspice simulation
  qt-rich/    question type: essay, manual or LLM-proposed grading
  qt-categorize/ question type: cards sorted into columns (ADR-036)
  qt-brainstorm/ question type: a brainstorm poll's ideas (ADR-071)
  diagram/    the diagram engine: scenes, kinds, router, editor (ADR-046)
  qt-diagram/ question type: a diagram, manual or LLM-proposed grading (ADR-046/063)
  qt-math/    question type: a formula in a math field, graded by sampling (ADR-102)
  qt-blocks/  question type: a program of blocks driving a warehouse robot (ADR-103)
docs/
  guide/      the user guide
  spec/       the product specification
  adr/        the architecture decision records
  development/  these pages
  assets/     the screenshots of the guide, light and dark
infra/
  keycloak/   the development realm imported by docker-compose.dev.yml
  engine/     deploy.sh, the engine VM's deploy dispatcher (runner + codespace, M6-04)
scripts/      smoke.sh, the end-to-end HTTP walk; staging-export.sh and
              staging-refresh.sh; changes.mjs and check-changes.mjs, the
              changelog's parser and guard (ADR-087)
changes/      What's new: one entry per pull request (its README, ADR-087)

This page owns the repository map; CLAUDE.md keeps the mandatory working rules and invariants. Workspace manifests are the source for exact packages and dependencies. Planned ui-kit, canonical and cli are described by their spec/merge tasks, not entries in the current tree.

At the root: Dockerfile (the application image), apps/runner/Dockerfile (the runner image), compose.prod.yml, compose.staging.yml, Caddyfile, Caddyfile.staging and deploy.sh for production and staging (the runbook is the deployment page); docker-compose.dev.yml for the optional development services; zensical.toml for this site; CLAUDE.md for the working conventions and the invariants.

Applications

apps/api

The Fastify monolith (ADR-001, ADR-002). src/server.ts loads the configuration (src/config.ts, a zod schema validated at startup), applies the migrations, builds the app (src/app.ts) and starts the ticker and the jobs. The modules live under src/modules/, the schema under src/db/, the migrations under drizzle/, the seed under src/seed/.

src/app.ts also serves /healthz and /metrics, described on the deployment page.

apps/web

The React single-page application (ADR-008), built with Vite. src/router.ts is the route union, src/api.ts the typed client over the contracts, src/i18n/ the dictionaries, src/mock/ the in-browser API used by pnpm dev:mock. Feature directories (activities, assist, attempt, calculator, changelog, coach, concepts, course, discover, drill, evaluation, github, gradebook, grading, group, help, journal, kiosk, live, markdown, notepad, notifications, oauth, pair, poll, pool, preview, project, question, realtime, results, runner, student) hold the screens and their tests; src/ui/ holds the generic primitives. The design rules are in apps/web/DESIGN.md, and .claude/skills/quiz-ui/SKILL.md is the checklist a screen goes through before it is declared finished.

apps/runner

The code execution service: Fastify on :3200, POST /run and GET /health, one hardened container per request driven over the Podman socket. It depends on @quiz/core only. apps/runner/README.md documents the flags of every container, the request lifecycle, the images under images/ and the configuration knobs. Its own image (apps/runner/Dockerfile) ships no engine, only the podman-remote client.

apps/codespace

The online workspace portal (ADR-047), imported from heig-classroom by merge task M6-03: Fastify on :3100, SQLite (better-sqlite3, Drizzle, migrations under drizzle/), one long-lived code-server container per (student, assignment) on the engine VM, an SEB exam mode, and a git channel on an internal bridge. A separate deployable that imports only packages/* (@quiz/domain for HS256, @quiz/contracts for the codespace schemas) and knows the platform through two signed HTTP messages. It has no login of its own and its GitHub relay is off (ADR-047, M6-03 amendment); its pages are in English and French (src/web/i18n.ts). Its CLAUDE.md records its invariants and its two sanctioned divergences from the runner's. Its own image, apps/codespace/Dockerfile (built from the root context by the CI, quiz-codespace:<sha>), runs on the engine VM as two instances, prod and staging; deploy/ holds the quadlet, unit and Caddy templates, install.sh (run by infra/engine/deploy.sh), bootstrap.sh and the runbook. images/ (the student image) is built on the VM by hand; infra/ holds the network scripts, nftables, AppArmor and seccomp the deploy installs. The application image does not contain it.

Packages

Package Entry points What it holds
@quiz/core ./server, ./client, ./rng the QuestionTypeServer and QuestionTypeClient contracts, the GradeResult union, the seeded RNG, the Runner interface and its request/outcome types
@quiz/registry ./server, ./client the two static maps from a question-type id to its implementation
@quiz/contracts . one zod schema per route and per SSE event, and the payload types both sides import
@quiz/domain ., ./<file> pure functions with unit tests: the Swiss grade scale, deadlines and time bonus, the MCQ and categorize scoring policies, the cloze parser, the roster import, output comparison, stats, pseudonyms
@quiz/seb . Safe Exam Browser, pure and server-side (node:crypto): the typed plist of a .seb (render and parse), the SEB-JSON rules and the Config Key, buildSebConfig, absoluteRequestUrl, expectedHash, hashesEqual (constant time); the Moodle vectors and the 201-key vector (D21, M6-02). The routes stay in apps/api/src/auth/seb.ts
@quiz/ui . the shared primitives of the question-type surfaces (React as a peer, @quiz/core its only dependency; it never imports a qt-* package nor apps/web)
@quiz/qt-mcq, qt-short, qt-cloze ./server, ./client one question type each: config schema, canonical form, grading on the server; Editor, Player and Review components on the client
@quiz/qt-code ./server, ./client two types sharing one program half: code, graded by the runner's test cases, and codeimage, judged by the picture its stdout draws (ADR-021)
@quiz/qt-rich ./server, ./client rich, shown as "Essay": a text graded against a rubric, by hand or from a language model's proposal (docs/spec/04 §4.8, ADR-063)
@quiz/qt-categorize ./server, ./client categorize, shown as "Categorize": cards sorted into columns, with distractors and an optional order, scored per card or all or nothing, and by the evaluation's negative marking (ADR-036)
@quiz/diagram ./server, ./client the diagram engine: the scene and its schema, the catalogue of eight kinds, the router, the text serialisers (./server, no parser); DiagramEditor and DiagramView (./client) (ADR-046)
@quiz/qt-diagram ./server, ./client diagram, shown as "Diagram": a diagram of the notation the teacher chose, drawn on the engine's canvas and graded by hand beside a reference (ADR-046)
@quiz/qt-circuit ./server, ./client, ./canvas circuit, a two-port schematic graded by simulating it with ngspice through the runner's spice language (ADR-019); ./canvas is the schematic editor
@quiz/qt-math ./server, ./client math, shown as "Math expression": a formula typed in MathLive's math field, stored as LaTeX, parsed on the server by Compute Engine and evaluated by a float interpreter of its own (@quiz/domain/math), compared to the accepted answers by their values at sample points drawn from the question, never a computer algebra system; an optional SVG graph (docs/spec/04 §4.16, ADR-102)
@quiz/qt-blocks ./server, ./client blocks, shown as "Blocks": a program of blocks, assembled in Blockly, that drives a warehouse robot to its dock, run on visible and hidden warehouses by the pure interpreter of @quiz/domain/blocks, in the browser and on the server alike; the answer is the package's own syntax tree, never Blockly's, and Blockly (src/blocklyBundle.ts) is reached only by a dynamic import (docs/spec/04 §4.17, ADR-103)

Server and client halves

@quiz/core/server contains no React anywhere and is what the API imports. @quiz/core/client describes the browser half of the contract; React appears there as type-only imports, which verbatimModuleSyntax erases, so the client entry adds no runtime dependency either. Every qt-* package follows the same split: server.ts exports the grading and the toStudent projection, client.tsx exports the lazy components.

The registries

packages/registry is the one place the API and the web app learn which question types exist. src/server.ts maps mcq, short, cloze, code, circuit, codeimage, rich, categorize, diagram, brainstorm, math and blocks to their *Server objects; src/client.ts does the same for the components. Registering a type is an import and an entry in each map. The registry depends on core and on the qt-* packages; nothing inside core may depend on the registry, or the package graph would cycle (decision D1 in the MVP plan).

API conventions

Modules

A module is a directory apps/api/src/modules/<name>/ built around four files (a module may split its service into cohesive files under its directory; service.ts stays the entry other modules import):

File Role
routes.ts the Fastify routes: parse the input with the contract schema, load the entity through a guard, call the service
service.ts the operations, callable from another module, from the seed and from the tests
events.ts the SSE events this module publishes
jobs.ts the background jobs it registers on the queue

A module never imports another module's routes.ts; it calls its service.ts. The seed (src/seed/demo.ts) obeys the same rule: it builds the demo world through the services, so every row it writes is one the application would have written.

A module may own no table at all: privacy composes the student views of the other modules into the personal data export (ADR-112), through their service.ts only.

Beside the module directories sit a few shared files that are not modules: src/modules/guards.ts, the access predicates every route loads an entity through; src/modules/http.ts, the common replies and the guarded-route wrappers studentRoute/teacherRoute; and a small plugin, avatar.ts. src/modules/runner/ is not a module either: it holds the API-side client of the runner (http.ts) and the stub that stands in for it (unavailable.ts).

Schema and migrations

The Drizzle schema is split by module under apps/api/src/db/ (assist.ts, auth.ts, changelog.ts, codespace.ts, concept.ts, drill.ts, evaluation.ts, github.ts, gradebook.ts, grading.ts, group.ts, importClassroom.ts, journal.ts, kiosk.ts, live.ts, llm.ts, notifications.ts, org.ts, poll.ts, pool.ts, project.ts, review.ts, system.ts) and re-exported by db/schema.ts; columns.ts holds shared column types and client.ts the connection. A table belongs to one module; another module may read it in a join but never writes it.

Migrations are SQL files under apps/api/drizzle/, generated, never hand-written:

pnpm db:generate         # drizzle-kit generate: a new NNNN_<name>.sql + meta/

They are applied at startup when MIGRATE_ON_START=1, which is the default in development and in the container image, on PGlite and on PostgreSQL alike. pnpm --filter @quiz/api db:migrate applies them by hand against a real PostgreSQL; it REQUIRES DATABASE_URL in the environment and stops without one, where pnpm db:generate only reads the schema and needs no database at all. Keep migrations additive: production rolls back by image tag, not by reverse migration (see deployment).

Tests

Suffix Where Runs on
*.test.ts every package, apps/runner, apps/codespace, apps/api plain Vitest, no I/O (the portal's git tests run real git on temporary repositories)
*.db.test.ts apps/api an in-memory PGlite created by apps/api/src/test/db.ts, migrated with the real files under drizzle/; testApp() returns a Fastify stub carrying that database and a TestClock, so a deadline is driven by moving the clock rather than by sleeping
*.test.tsx apps/web, the qt-* clients Vitest with jsdom; apps/web runs its .test.ts files in a plain node project and its .test.tsx files in a dom project
*.leak.test.ts, toStudent.test.ts apps/api/src/modules/live, every qt-* the content-safety tests of invariant 4
*.int.test.ts apps/runner real containers, pnpm --filter @quiz/runner test:integration, skipped without Podman
listed in vitest.config.ts apps/codespace rootful Podman and a forge, pnpm --filter @quiz/codespace test:integration (CODESPACE_INTEGRATION=1), never in CI

On the shared workstation run VITEST_MAX_WORKERS=4 pnpm -r --workspace-concurrency=1 test (one command), following AGENTS.md's memory guard. The runner integration suite is separate.

The cross-type contract of the question types is one test, packages/registry/src/server.test.ts: for every registered type it runs toStudent on the type's FULL configuration (several seeds, shuffle on and off) and searches the output for every forbidden key and every secret value, and it checks the empty draft, configVersion and migrate. Each qt-* package supplies that configuration from src/testing.ts, exported as the test-only ./testing subpath (nothing in apps/* imports it), with the keys and values only it must never publish; the helper is findStudentLeaks from @quiz/core/testing. A new type does not pass without one. The package's own toStudent.test.ts keeps what is specific to the type.

Internationalisation

Everything a user reads goes through t() from apps/web/src/i18n/index.tsx, teacher screens included. The dictionary is flat: en is the source of truth, and fr is declared as Record<keyof Dict, string>, so a key added in English without its French twin fails pnpm typecheck. The qt-* packages carry their own strings in src/strings.ts, which the host merges.

Everything else, code, identifiers, comments, commit messages, ADRs and this documentation, is in English.

The invariants

The invariants a change must respect (English everywhere but the UI, one primary action per screen, no development login in production, toStudent as the only exit of question content, the server's clock, access loaded through staffAccess, contract schemas on every input, pure rules in @quiz/domain, the closed audit union, and the five runner invariants) are written once, in CLAUDE.md at the root of the repository. A change that needs one of them relaxed is a change to discuss first, not a // temporarily comment. The runner's hardening flags are documented in apps/runner/README.md.

  • 5. Architecture: the layout, the database schema, real-time, the runner, grading.
  • 7. Reuse of heig-classroom: what was kept, adapted or dropped from the sibling project this repository started from.
  • The MVP plan: the decisions numbered D1 to D20 that the code comments refer to; the phase-1 work packages and the sections cited as "PLAN-MVP §x.y" are in its archived full copy.