Write front-end code in the browser and they will be graded in seconds!
Quick start · The workspace · How grading works · Provisioning · Security · Deployment · API · Architecture · Testing
Solving an exercise end to end — write, preview, run the suite, submit:
For students — a file manager and a code editor with syntax highlighting,
bracket matching, multi-cursor-free sanity and autosave. A live preview that
resolves your own files. A console for your own console.log. An element picker
that hands you a selector. Sample tests you can run as often as you like, and a
report that names what was expected and what it found.
For instructors — Markdown statements with a real editor. 128 checks configured rather than coded, filtered in either language, across seven judges — from a parser that never runs anything to a container that builds a React app. Weighted tests, sample tests, hidden tests and a scoring policy you choose per exercise. Validate an exercise against your own reference solution before publishing it. Bulk regrade. Compare a student's revisions. A gradebook with late penalties applied at read time.
For whoever runs it — the API cannot execute student code, and that is tested rather than asserted. Editable roles over 42 permissions. An audit log. A judge fleet for the suites that genuinely must run, where each worker declares which judges it can serve so a heavy Cypress image and a plain Node image can share one queue. Docker, Compose, Kubernetes, Ansible and OpenTofu manifests, and a provisioner that fills an empty instance with a working course.
A student opens an exercise, writes HTML, CSS and JavaScript across as many files as the task needs, sees the result render live, and gets a graded report naming exactly which requirement failed and why. An instructor authors exercises and assertions through a schema-driven UI, validates them against a reference solution before publishing, and reads back which concept the class has not understood.
To grade a submission you must understand it. The obvious way is to execute it — and the obvious way is a remote-code-execution vulnerability with a grade attached.
KNJudge answers this in three different ways, chosen per exercise, and is explicit about which one it is using:
| Judge | How | Executes code | Result |
|---|---|---|---|
| Static | Reads the source with a hand-written HTML parser, CSS parser, selector engine and JavaScript analyser | Never | Verified |
| Browser | Runs in the student's own browser, in a doubly-sandboxed opaque-origin iframe | Yes, on their machine | Unverified — scores zero by default |
| Worker | Runs the instructor's Jest or Cypress suite in a disposable container | Yes, in isolation | Verified |
Most questions are answered by reading, which is why the static families are the largest. But "do the student's tests pass" cannot be — so the platform gained a second process type whose entire job is to run untrusted code, and the security claim became narrower and sharper:
The API process cannot execute student code. Not "does not" — cannot.
That is checkable rather than asserted. npm run verify greps the whole API for
eval, new Function, vm and child_process, and walks the API's real
require graph from app.js and fails if it can reach server/worker/ by any
path. Either check alone is weak; together they say the API cannot run student
code because the code that runs student code is not reachable from it.
A judge worker is designed assuming the process inside it is already compromised: no database credentials, no network egress except DNS and the API, read-only root, unprivileged user, all capabilities dropped, and a workspace destroyed after every job.
The browser sandbox and the judge worker are both untrusted, and both are constrained the same way: they report what they observed, never a verdict.
The server re-evaluates that observation against the stored assertion config and
records the result flagged verified: false. Under the default scoring policy it
contributes nothing to the grade. A student who forges a perfect observation
gains exactly what the honest one gains: zero.
A judge worker is held to the same rule. It is never told the test weights — the score is recomputed on the API from what is stored — so a compromised worker can lie about whether a test passed but cannot change what that is worth, decide a test it was not given, or make an unreported test pass silently.
Twelve browser-level security tests attack this boundary — reading cookies, reaching
parent.document, callingfetch, spinning an infinite loop, injecting<img onerror>. All fail, as designed. See SECURITY.md.
Requirements — Node.js ≥ 18.18 and a MongoDB replica set (or Docker).
git clone <this-repo> knjudge && cd knjudge
npm install
cp .env.example .env # works as-is for local development
docker run -d -p 27017:27017 --name knjudge-mongo mongo:7 \
mongod --replSet rs0 --bind_ip_all
docker exec knjudge-mongo mongosh --quiet --eval \
"rs.initiate({_id:'rs0',members:[{_id:0,host:'127.0.0.1:27017'}]})"
npm run migrate # apply schema migrations
npm run provision # 34 exercises, a cohort, a submission history
npm start # http://localhost:4000Or bring the whole stack up — API, workers, MongoDB and Redis — in one command:
docker compose -f infra/docker/compose.yaml up -d --build --scale worker=2
TARGET=compose ./scripts/provision.sh --scale labThere is a Taskfile if you prefer named targets: task up,
task provision, task check, task down.
There are no demo accounts and no shared password. Provisioning generates a
distinct random password per account and writes them to
.provisioned-accounts.txt — mode 0600, git-ignored:
cat .provisioned-accounts.txt
# admin admin@knjudge.local 8sQ2p…
# instructor instructor@knjudge.local Kd41v…
# student dwin.gharibi@student.knjudge.local 9xUb2…Pin the administrator to your own address, and supply its password out of band:
PROVISION_ADMIN_PASSWORD="$(openssl rand -base64 24)" \
npm run provision -- --admin=you@university.eduIf nobody has provisioned yet, the very first account to register becomes the administrator — and only that one. Once an administrator exists, every subsequent registration is a student, whatever the user count says.
npm run dev # auto-restart on change
npm test # unit + integration (~40 s)
npm run test:e2e # Playwright, real browser (~2 min)
npm run verify # routes, docs and catalogue agree
npm run lint # ESLint + Stylelint
npm run migrate:status # which migrations have run
npm run provision -- --reset --scale=faculty # 120 students
npm run worker # a judge worker against a local APIA file tree, a code editor, a live preview and the test report — on one screen, with nothing to save and nothing to compile.
The file manager is a real one. Create files, nest them in folders by naming
them that way, rename, duplicate, delete — and the preview resolves your own
<link> and <script src> against your own tree, so a multi-file project
previews the way it is written.
Every assertion, with what was expected, what was found, its weight, and the evidence behind the verdict. A failure that says only "failed" is a failure the student cannot act on.
Your own console.log, in the pane, as it happens — objects formatted rather
than [object Object], errors in red, and none of it able to reach the app
around it.
The form is generated from each assertion's own schema, so adding a check is choosing one and filling it in. The picker filters all 128 in either language.
Statements are Markdown, with a real editor: toolbar, shortcuts, and a preview that keeps up.
Open a submission, read exactly which assertions failed, and leave feedback against it. A score override is recorded as an override, not as a new score.
Two revisions of the same submission, side by side: which tests were fixed, which broke, and the line diff underneath.
Score distribution, the assertions the class fails most often, and a gradebook where a blank is a blank rather than a zero.
Live workers, their declared judges, dispatch health, and a warning for any judge nothing in the fleet can run.
The same interface in light and dark, and in English and Persian. Direction is not a stylesheet override — the layout is written in logical properties, so it mirrors rather than being mirrored.
Every other screen — sign in, catalogue, exercise detail, submissions, progress, leaderboard, management, users, audit log, settings
Sign in
The exercise catalogue
An exercise
The student dashboard
A submission
Progress
The leaderboard
Managing exercises
The review queue
Users
The audit log
Instance settings
flowchart LR
A["Student writes<br/>HTML · CSS · JS"] --> B{Assertion mode}
B -->|static| C["Server<br/>parses the source"]
B -->|runtime| D["Sandboxed iframe<br/>in the student's browser"]
C --> C1["HTML parser → DOM"]
C --> C2["CSS parser → stylesheet"]
C --> C3["JS lexer → analysis"]
C1 & C2 & C3 --> E["evaluate(config)"]
D --> D1["Observations only<br/>never a verdict"]
D1 --> F["Server re-evaluates<br/>against stored config"]
E --> G["verified: true"]
F --> H["verified: false"]
G --> I[("Score<br/>persisted")]
H -.->|"0% under the<br/>default policy"| I
style D fill:#3a2d5c,stroke:#9b5bff,color:#fff
style D1 fill:#3a2d5c,stroke:#9b5bff,color:#fff
style H fill:#5c3a2d,stroke:#ff8c00,color:#fff
style I fill:#1f4d2e,stroke:#2ea043,color:#fff
A judge is the answer to one question: where does this check get decided?
An exercise picks one, every test belongs to it, and the report says which judge
produced each line — because "your code failed" means something very different
when a parser said it than when a container full of somebody else's npm
dependencies did.
| Judge | Runs in | Decides | Verified | Needs |
|---|---|---|---|---|
static |
the API process | 123 kinds, by parsing the submitted HTML, CSS and JS | ✅ | nothing |
runtime |
the student's browser, two frames deep | whether clicking the thing actually changed the DOM | nothing | |
jest |
a worker container | one case per named test in a Jest suite | ✅ | Jest in the bundle |
cypress |
a worker container | one case per named test in a Cypress spec | ✅ | Cypress and a browser in the image |
command |
a worker container | any command line that emits JUnit, TAP or an exit code | ✅ | whatever that command needs |
react |
a worker container | install → build → test, staged | ✅ | vendored node_modules |
express |
a worker container | install → migrate → test, staged | ✅ | vendored node_modules |
static is the default, and it is the one that scales. It never executes
anything: HTML becomes a DOM, CSS becomes a stylesheet, JS goes through a lexer,
and the assertions ask questions of those trees. A submission cannot influence
the process grading it, the verdict is identical every time it runs, and it
costs a few milliseconds — which is why 33 of the 34 provisioned exercises need
no worker, no queue and no second machine at all.
runtime asks the browser what happened, never what it is worth. Some
requirements are only true in motion — the counter increments when the button is
clicked is not a fact about source text. So the sandboxed iframe performs the
interaction and reports observations: this selector's text was 1, no error
was thrown. The API re-evaluates those observations against the stored test
config and marks the result verified: false, because it came from a machine
the student owns. Under the default policy, that scores zero. See
the security model for why this is a feature and not a
limitation.
jest, cypress and command are "bring your own suite". Jest runs with
--ci --json --outputFile and the cases are matched back to the exercise's
tests by name. Cypress gets the submission served from a loopback static
server the worker starts itself, so the specs can visit a real URL without the
container needing any network access. command is the escape hatch — any
command line at all, plus reportFormat: junit | tap | exit-code — and it is
what you reach for when your grading tool is neither of the first two.
react and express are the command judge with the pipeline already written
down. An instructor grading a React app should not have to remember that it
needs an offline npm ci, then a build, then a test runner pointed at a JUnit
reporter; they should pick React and fill in the parts that are actually
about their exercise. The steps run in order and stop at the first failure,
so a project that does not install reports install failed instead of four
hundred lines of a test runner complaining about missing modules.
react install ──▶ build ──▶ test npm ci --offline
vitest run --reporter=junit
express install ──▶ migrate ──▶ test npm ci --offline
jest --ci --reporters=jest-junit
The Express judge deliberately has no "start the server" step. A suite that
boots the app in-process with supertest is deterministic and needs no port;
one that starts a detached server and curls it is a race with a cleanup problem.
The exercise should be written the first way, and the absence of that step is
how the judge says so.
Every worker judge shares one sandbox. spawn with shell: false and no
string interpolation, an environment cut down to six allow-listed variables, a
detached process group killed as a tree on timeout (SIGTERM, then
SIGKILL five seconds later, so nothing survives by forking), 512 KB of
captured output per stream after which it is truncated rather than buffered, no
network egress, no database credentials, and a workspace directory removed when
the job ends whether it passed, failed or crashed.
Judges are capabilities, not assumptions. A worker declares what it can run
when it registers (JUDGE_WORKER_JUDGES), and the claim endpoint offers it only
jobs whose judge is both in that list and enabled in settings. So a heavy
Cypress image and a plain Node image can sit in the same fleet without either
claiming work it would only fail — and turning off a judge platform-wide is a
settings toggle, not a redeploy. The admin fleet page shows which judges
currently have no live worker, because a queue that silently never drains is
worse than one that says why.
Each kind is self-describing: it carries its own field schema, validator, human-readable description and evaluator in one place. The authoring form, the validation messages and the report rendering are all derived from that schema, so adding an assertion kind means editing exactly one file — and its form appears in the UI with no client change at all.
| Group | Kinds | Examples |
|---|---|---|
| JS | 38 | uses API, event listener, pattern, structural metric, async/await vs promises, module syntax, template literals, strict equality, comment ratio, forbidden APIs |
| HTML | 26 | element exists / count, text, attribute, class, nesting depth, sibling order, unique ids, heading order, table structure, form controls, meta tags |
| CSS | 22 | selector exists, declaration (shorthand-aware), media and container queries, custom properties, layout mode, clamp(), logical properties, no !important |
| A11y | 10 | image alt text, form labels, document language, landmarks, link text, button name, positive tabindex, ARIA role / attribute / labelled control |
| Project | 7 | file exists, entrypoint, package.json field, file content, file count, no build artefacts, directory shape |
| Perf | 6 | HTML / CSS / JS size, DOM node count, image dimensions, script loading |
| Security | 6 | no inline handlers, no innerHTML, no eval, external-link rel, no hard-coded secrets, form method |
| React | 4 | component defined, JSX shape, hook usage, no array-index keys |
| Express | 4 | route registered, middleware mounted, status code, error handler |
| Runtime | 2 | interaction sequence → DOM assertion, no runtime errors |
| Jest · Cypress · Command | 3 | one case per named test in your own suite |
123 of those are decided by reading the source, two by observing the
student's own browser and three by running a suite in a worker — which is the
same thing as saying that almost every exercise on this platform grades with no
container, no queue and no second machine. /api/meta serves the whole
catalogue, which is why the authoring UI and the settings panel never hard-code
a list.
A hundred-odd checks is a wall, so the authoring picker filters across kind,
group, title, description and field labels, in both languages at once — every
term has to match, so a11y image finds the alt-text check without anyone
recalling its exact wording. The check you currently have selected stays pinned
in view even when the query excludes it, because a picker that appears to have
nothing selected while the form below is editing something is a lie.
A test can be marked a sample: a worked example the student can run as often as they like, always visible, never counted in the score. It is the shape every competitive judge uses — a few checks that confirm you understood the question, and a separate set that decides the mark — and the workspace gets its own Run sample tests button for them.
The two flags are mutually exclusive and the model enforces that, not the controller: a hidden worked example teaches nothing, and tests are created by the authoring API, the provisioner, exercise duplication and the fixtures — four callers, one of which would eventually forget. A sample's weight never reaches the score, but it is still run, still reported and still counted in the pass/fail tallies, so "6 of 6 passed" stays honest while the grade ignores the ones that were only ever demonstrations.
A few details that matter in practice:
css.declarationnormalises values.#fff,whiteandrgb(255,255,255)are the same answer, and shorthands are expanded — a student who writesfont: bold 16px/1.5 sans-serifsatisfies an assertion onfont-weight.js.patternstrips strings and comments before matching, so a test cannot be passed by mentioning the answer in a comment.- Regex configs are screened for catastrophic backtracking and rejected at authoring time.
- A failing result always carries
expectedandactual. "Failed" teaches nothing; "expecteddisplay: flex, founddisplay: block" teaches the lesson in one line.
A score is not "how many tests passed". Six rules decide the number, and all six are visible to the people the number is about:
score = earned weight ÷ scored weight × 100
scored weight = verified tests ← policy: verified-only (default)
= verified tests + runtime tests ← policy: combined
sample tests never enter either total
late penalty is subtracted when the gradebook is read, not when the score is stored
- Weights, not counts. Every test carries a weight (default
1), so "the layout is responsive" can be worth five times "the page has a title" without writing five copies of it. The report shows earned and total weight separately, so a 60% is legible as which 40% went missing. verified-onlyis the default, and it is a security rule wearing a grading hat. Anything the student's own browser reported scores zero. It is still run, still shown and still marked in the report — a student sees that their interaction test passed and that it did not count, which is more honest than hiding it.combinedexists for the cases where counting it is defensible (a supervised in-class exercise, a formative task), it is chosen per exercise, and each submission stores the policy that produced its number — so changing the default later never silently rewrites a grade that was already given.- Sample tests are run, reported, and never counted. A worked example the
student can run as often as they like.
sampleandhiddenare mutually exclusive and the model enforces it, not the controller: a hidden worked example teaches nothing. - Hidden tests count, and may not even be named (
showHiddenTestTitles). The pass/fail tallies stay truthful either way — "6 of 6 passed" counts the samples it ran, while the grade ignores them. - Limits are course policy, not code. Attempts per exercise
(
defaultMaxSubmissions,0= unlimited), whether late work is accepted, and the penalty per day late are settings. Because the penalty is applied at read time, extending a deadline fixes every affected grade at once — no regrade, no migration, no spreadsheet. - Retries belong to the job, not the student. A worker dying mid-suite is
the platform's problem: the job is reaped, requeued up to
jobRetryLimittimes, and a resurrected worker's late report is discarded by its fencing token. None of that consumes a submission attempt.
And one rule the platform applies to itself: the API recomputes every score it stores. A worker and a browser both report results, never a number. The summariser runs on the API, against the test configuration in the database, and what it produces is what gets recorded — which is why a compromised worker or a patched browser can lie about a test and still not change a grade.
POST /api/exercises/:id/validate grades the exercise's own reference solution
against its own tests. It exists to catch the most common authoring mistake — an
assertion with a subtly wrong selector that nobody can pass, author included.
The provisioner runs it for all 33 static exercises and exits non-zero if any scores
below 100.
Three panes — editors, a live preview, a graded report — plus the things that turn it from a text box into somewhere you can actually work:
- A console. The student's own
console.log, in the pane, streamed as it happens. Before this the only way to see one was to open devtools and find the sandbox frame in the frame picker, which is a lot to ask of somebody still learningaddEventListener. - An element picker. Hover to outline, click to get
#app .card > buttonon the clipboard. The mistake that costs students the most time is not the CSS but the selector the test is asking about; this turns a guess into a fact. - Sample tests, run on their own (below).
- Syntax highlighting, auto-indent, bracket and quote pairing, Tab/Shift-Tab block indent, phone and tablet preview widths, autosave, and a standalone HTML export.
Both the console and the picker cross the sandbox boundary in the safe direction: the student's document reports a string upward, and nothing travels down but a mode flag. No handle, no reference, nothing the application can be called back through.
Statements are Markdown, written in a proper editor: a toolbar, Ctrl+B /
Ctrl+I / Ctrl+K, list continuation, and write / split / preview modes with
the preview updating as you type. Every edit goes through insertText so the
browser's own undo stack survives — losing an hour of statement writing to a
single Ctrl+Z is not a trade worth making.
Tests are authored from the catalogue with a filter across kind, group, title, description and field labels, in both languages at once. The picker and the generated form sit side by side, because stacked vertically the 128-entry list pushed the form for the check you just picked off the bottom of the modal.
An empty instance is not a demo, and a demo with four accounts sharing one
published password is not deployable. npm run provision fills a fresh
database with a course that behaves like one that has been running for a term.
npm run provision # 32 students, the default
npm run provision -- --scale=demo # 8 students, fastest
npm run provision -- --scale=faculty # 120 students
npm run provision -- --reset # drop everything first
./scripts/provision.sh --scale lab # local, compose or k8s
TARGET=k8s NAMESPACE=knjudge ./scripts/provision.shWhat it creates:
| 34 exercises | Full statements, starter trees, reference solutions and weighted suites — HTML semantics, accessibility, Flexbox, Grid, custom properties, the cascade, DOM events, async, ES modules, XSS, performance budgets, RTL, and a worker-judged project |
| 284 assertions | Across every assertion family, with hidden and sample tests where the exercise calls for them |
| A cohort | Staff plus a generated student body, in three sections, with student numbers |
| A history | Multiple revisions per student with a believable spread of outcomes, feedback on some, and a review queue with something in it |
Two properties matter more than the content.
It is idempotent. Exercises key on their slug and accounts on their email, so running it twice changes nothing. Deterministic too — the same scale always produces the same cohort, so screenshots and fixtures do not drift.
It self-checks. Every reference solution is graded against its own suite, and the process exits non-zero if any of them fails to reach 100% of the statically verifiable weight:
Reference-solution self-check
33/33 reference solutions score 100% on their own suite
An exercise whose own answer cannot pass its own tests is a broken exercise. CI runs this, so one cannot ship quietly.
Schema changes are not free just because MongoDB will not stop you. A field that used to be a string and is now an array is a field every read has to defend against — unless somebody rewrites the old documents.
npm run migrate # apply everything pending
npm run migrate:status # what has run, and when
npm run migrate:down # roll the most recent one backMigrations run in filename order, exactly once, recorded in a migrations
collection. A failure is not recorded, so the next run retries it. The runner
takes a lock first, because a rolling deploy starts several API replicas at
once and "backfill every submission" should happen one time, not four.
The demo accounts are a convenience, not the product. Everything a course actually needs is here.
Authorisation is by permission, not by role name. A role is a named set of
42 permissions, stored in the database and editable — student, ta,
instructor and admin are simply the four seeded on first boot. An instance
that wants "a TA who reviews submissions but does not publish exercises" creates
that role; no code changes.
- Custom roles, with a permission editor that marks the dangerous ones.
- Invitations — token-based, bound to the address they were issued to, and only ever stored as a SHA-256 hash. Registration can be closed entirely so an invitation is the only way in.
- CSV roster import, with a dry run and a per-row reason for every rejection — a class list with one bad address says which row, rather than failing at row 43 having already created 42 accounts.
- Cohorts with staff, members, exercise scoping and join codes, so "the class average" is a meaningful number when two sections share a deployment.
Every path that could lock an instance out is guarded: an administrator cannot narrow the last role that can administer, demote the last administrator, suspend themselves, or delete a role still in use.
An exercise is a Markdown statement plus, optionally, an init.zip starter
project and attachments. A student either works in the file-based editor or
uploads a whole project — the author chooses which, because it follows from the
exercise.
Uploads are treated as hostile, because a submission is. The ZIP reader refuses path traversal, symlinks, encrypted entries, duplicate names, entry floods and zip bombs — the bomb check runs on the declared size before anything is inflated, and again through zlib so a lying header cannot win either. The declared Content-Type is a hint; the real type comes from the magic bytes.
The project.* judge family is what makes an uploaded project gradeable: a file
exists, package.json declares a script, no build artefacts were included.
project.entrypoint even names the wrapping-folder mistake explicitly — zipping
the folder rather than its contents is the commonest upload error by a wide
margin, and "index.html is missing" would send a student hunting for a file that
is plainly there.
Which judge does what is above; this is where the ones that need a container actually run.
┌──────────────┐
students ───────▶│ API ×N │───▶ MongoDB the record
│ never runs │
│ student code │
└──┬────────┬──┘
publishes │ │ claims
▼ │
┌──────────┐ │
│ Redis │ │ job ids only
│ Streams │ │
└──────────┘ │
┌───────────▼──────────┐
│ judge workers ×M │ the only place student
│ no egress │ code ever executes
│ no DB credentials │
└──────────────────────┘
Job dispatch has two drivers. Without a broker, workers poll MongoDB —
findOneAndUpdate is atomic, so claims are correct with no extra
infrastructure, at the cost of up to one poll interval of latency. Set
JUDGE_BROKER_URL and workers block on a Redis Streams consumer group
instead, woken the moment work arrives.
MongoDB stays the record either way; the broker carries job ids only. No submission content ever enters Redis, so losing it costs latency rather than work: jobs are still queued, the reaper recovers anything in flight, and dispatch falls back to polling rather than refusing to start.
Scaling is the point of the queue — the API is unchanged whether there is one worker or forty:
docker compose -f infra/docker/compose.yaml up --scale worker=6
kubectl -n knjudge scale deploy/knjudge-worker --replicas=8Four paths, in infra/: Docker Compose for one machine,
Kubernetes manifests, OpenTofu for the cluster-side resources, and Ansible with
systemd units for plain VMs. All four are consistent with the application's real
configuration surface, and the manifests carry the controls that make a worker
safe — most importantly the NetworkPolicy that permits egress to DNS and the API
and nothing else.
Full threat model in SECURITY.md, where each control is cross-referenced to the test that proves it. In brief:
| Threat | Control |
|---|---|
| RCE via submitted code | Never executed server-side. No eval, new Function, vm or child_process — enforced by npm run verify as a repository-wide check |
| XSS via submitted code | Rendered only inside sandbox="allow-scripts" on an opaque origin (no allow-same-origin), with connect-src 'none' |
| XSS via the app itself | The client's el()/render() set textContent only and never assign innerHTML — injection is structurally impossible, not filtered |
| Forged grades | The client may report observations, never verdicts. Sandbox results are verified: false and score 0 by default |
| NoSQL injection | $-prefixed and dotted keys stripped from every input; strictQuery and sanitizeFilter on; legitimate operators wrapped in mongoose.trusted() |
| Credential theft | bcrypt (12 rounds); refresh token in an HttpOnly cookie, rotated per use, stored only as a SHA-256 hash; access token in memory only |
| Session persistence after revocation | A monotonic tokenVersion claim — a password or role change invalidates every outstanding access token immediately |
| Brute force | Per-route rate limits, plus account lockout after 8 failures. Unknown addresses still pay the bcrypt cost, so timing reveals nothing |
| Privilege escalation | Role checks on every route; ownership enforced in the query, so a cross-student read is a 404, not a filtered result |
| Resource exhaustion | Payload caps, a grader time budget, a DOM node ceiling, and ReDoS screening on author-supplied patterns |
| Repudiation | Every privileged mutation writes an audit entry; entries expire via a TTL index after 365 days |
Two nested sandboxes rather than one: the outer frame is denied
allow-same-origin, which puts it on an opaque origin where document.cookie,
localStorage and same-origin access to the parent are all unavailable by
construction rather than by policy. The inner frame isolates the student's
document from the harness that observes it.
A judge worker is the same idea at container scale — and note what it is handed in the diagram above: ids, titles and configs, but never the weights.
Every diagram below is generated by npm run diagrams from
scripts/diagrams/architecture.py — code
that lives beside the code it describes, for the same reason the screenshots
are captured from the running application. A picture maintained separately from
the thing it draws is wrong within a month.
Four processes and one line that matters: what the API trusts, and what it only listens to. The student's browser and the judge fleet are both on the far side of it.
Students and instructors reach the API through an ingress; the API owns MongoDB and GridFS and publishes job ids to Redis Streams; workers claim from the stream and report back over HTTP. Nothing in the fleet has a database credential.
The API decides everything. The browser sandbox and the judge worker may report observations; neither may report a score.
Chosen per exercise, and the platform is explicit about which one produced each result — a browser-run verdict is recorded as unverified and scores zero under the default policy. The seven judges are the concrete implementations of these three paths.
Sources stored, static grading on the API, behaviour tests deferred to the browser, heavier suites to a worker — and every verdict recomputed on the API before anything is recorded.
Queued, claimed under a lease, heartbeated while it runs, fenced if it is reassigned, and reaped if the worker dies mid-flight.
The workspace embeds the sandbox runner in an opaque-origin iframe; the runner embeds the student's document in a second sandboxed iframe. Only strings travel back up.
Upload, archive checks, GridFS, and who is allowed to read one back.
Roles are editable rows in a collection. The lockout guards — a last administrator cannot demote or suspend themselves — are not.
Which results count, which are recorded but not counted, and what is applied at read time rather than baked in.
Full detail — the data model, every index, and the request lifecycle in prose — is in ARCHITECTURE.md.
The brand lives in
docs/media/brand/— the mark, the theme-aware README banners, and the social-preview card. All of it is SVG; the one raster GitHub insists on is rendered from its SVG source bynpm run brand, never edited by hand.
knjudge/
├── client/ # No build step — served as-is
│ ├── index.html
│ ├── sandbox/runner.html # The isolated execution frame
│ └── assets/
│ ├── css/ # tokens → base → components → layout → pages → motion
│ ├── fonts/ # IRANSansX (fa) + JetBrains Mono, self-hosted
│ └── js/
│ ├── core/ # dom · api · router · store · i18n · ui · icons
│ ├── components/ # editor · charts · sandbox · results · shell
│ ├── pages/ # one module per route
│ └── i18n/ # fa.js · en.js — key parity enforced by a test
├── server/
│ ├── src/
│ │ ├── config/ models/ routes/ controllers/ services/ middleware/
│ │ └── lib/ # html/ · css/ · selector/ · js/ ← the analysis engine
│ ├── scripts/provision.js
│ └── tests/ unit/ · integration/ · e2e/
├── scripts/ capture-demo.js · verify.js · render-brand.js · diagrams/ · lib/
└── docs/ API.md · ARCHITECTURE.md · SECURITY.md · TESTING.md · media/
The client is vanilla ES modules loaded directly by the browser. No bundler, no
transpiler, no framework, no node_modules in the browser's path. Edit a file,
reload, see the change — and what ships is what was written, which makes the
security properties inspectable rather than trusted.
The trade-off is real and deliberate: no tree-shaking and no minification, in exchange for a codebase where every line the browser runs is a line in this repository.
Seven runtime dependencies, all load-bearing:
| Package | Why |
|---|---|
express |
HTTP framework |
mongoose |
MongoDB ODM, schema validation |
jsonwebtoken |
JWT signing and verification |
bcryptjs |
Password hashing |
cookie-parser |
Reading the refresh cookie |
cors |
Cross-origin policy |
dotenv |
.env loading |
Three dev dependencies: @playwright/test, supertest, mongodb-memory-server.
Everything else is written here — the HTML parser, the CSS parser, the selector engine, the JavaScript analyser, the validator, the rate limiter, the syntax highlighter, the Markdown renderer, the charts, the PNG decoder and the GIF encoder. The project's requirement was that the core logic be the team's own; the dependency list is where that shows.
Persian and English, with full RTL. Direction is handled with CSS logical
properties throughout — margin-inline-start, inset-inline-end,
border-start-start-radius — rather than a mirrored stylesheet. There is one
layout, and it flips.
A unit test enforces key parity between the two dictionaries and checks that
every t() key used anywhere in the client actually exists, so a
half-translated release cannot ship.
Persian text is set in IRANSansX, code in JetBrains Mono; both are
self-hosted with font-display: swap. Latin text uses the platform's own UI
face — nothing to download on the pages most people see. Both webfonts are
scoped with unicode-range, so the mono face never steals Persian glyphs and
an English session never fetches IRANSansX at all.
| Component | Instead of |
|---|---|
| Syntax highlighter | Prism / highlight.js — own tokenizers for HTML, CSS and JS |
| Code editor | CodeMirror / Monaco — textarea with a highlighted overlay, so it stays accessible and light |
| Charts | Chart.js / D3 — inline SVG, animated with @property |
| Markdown renderer | marked — emits text nodes only, so an exercise statement cannot inject script |
| Router | — hash-based, with per-route containers so a slow render cannot overwrite a newer page |
Keyboard navigable throughout, with a skip link, focus trapping in modals, live
regions for toasts, aria-* on every custom control, one <h1> and a labelled
<main> per page, and AA contrast in both themes. Four E2E tests check these
properties rather than assuming them.
Page transitions, staggered list reveals, button ripples, an animated score dial,
pass/fail flashes, chart draw-in, and confetti on a perfect score. All of it sits
behind a complete prefers-reduced-motion opt-out — and the screenshot capture
runs with motion disabled, so an animation is never caught mid-flight.
The things an audit actually found, and what was done about them:
| Problem | Fix | Effect |
|---|---|---|
| Nothing was compressed | A gzip/brotli middleware written against node:zlib — no new dependency |
74% off the wire: 177 KB of JS, CSS and JSON becomes 46 KB |
| Regrade loaded every submission at once, then blocked the event loop through all of them | A cursor in batches of 25, yielding between them | Bounded memory; other requests keep being answered during a regrade |
| Adding a cohort by paste issued one query per name, up to 1000 round trips | One $or and an in-memory index |
1 round trip |
| The landing hero was a 2880×1800 PNG rendered at 1000 CSS pixels, downloaded on phones where it is an unreadable smudge | Captured at 1x, and not rendered at all below 720px | 251 KB → 102 KB, and 0 on a phone |
| The assertion search rebuilt up to a hundred DOM subtrees per keystroke | 90 ms debounce | No dropped characters |
One thing deliberately not changed: both language dictionaries still load on
every page. Splitting them would mean making t() asynchronous, which is
load-bearing in every component in the client — and with compression the unused
one costs about 9 KB. That is not a trade worth the risk.
543 tests. Full breakdown in TESTING.md.
Unit 225 parsers, selector engine, 128 judge components, ZIP,
multipart, CSV, RESP, JUnit/TAP, i18n, GIF encoder
Integration 158 HTTP surface, permissions, uploads, judge queue,
persistence, aggregations
E2E 42 30 user journeys + 12 security attacks, in a real browser
Verify 10 structural pre-flight checks
Nothing needs a running MongoDB — the integration and E2E suites boot an
ephemeral in-memory server. A fresh clone runs everything with
npm install && npm run test:all.
The security tests are the ones worth reading. They do not assert that a control
is configured; they attack the sandbox from inside a real browser and assert the
attack fails. The clearest is the infinite-loop test: while (true) {} in student
code hangs its own iframe, the host page stays responsive, and the server never
sees the code at all.
| Document | Contents |
|---|---|
| API.md | Every endpoint, request and response shape, error codes, the full assertion catalogue |
| ARCHITECTURE.md | Layering, data model, request lifecycle, sequence and ER diagrams, deliberate limitations |
| SECURITY.md | Threat model T1–T10, each control mapped to the test that proves it |
| TESTING.md | How to run, read and extend the suite |
| .env.example | Every configuration variable, annotated |





























