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:
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.
Related reading¶
- 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.