Skip to content

Repository files navigation

KNJudge — automated exercise and assessment for HTML, CSS and JavaScript

Write front-end code in the browser and they will be graded in seconds!

Tests Unit Integration E2E Node Express MongoDB Redis Build step Licence

Assertions Judges Exercises RBAC i18n A11y Infra

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:

Typing a solution in the workspace, watching the live preview update, running the test suite and submitting for a perfect score



At a glance

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.

The KNJudge landing page in dark theme


🎯 What KNJudge is

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.

🧩 The central problem

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.

What the untrusted side is allowed to say

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.

// what the browser is allowed to say
{ "test": "", "observed": { "text": "3" }, "errors": [], "durationMs": 34 }

// what it cannot say
{ "passed": true }   // ← there is no such field; the server decides

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, calling fetch, spinning an infinite loop, injecting <img onerror>. All fail, as designed. See SECURITY.md.


⚡ Quick start

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:4000

Or 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 lab

There is a Taskfile if you prefer named targets: task up, task provision, task check, task down.

Signing in

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.edu

If 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.

Everything else

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 API

🖼️ A tour

The workspace

A file tree, a code editor, a live preview and the test report — on one screen, with nothing to save and nothing to compile.

The workspace: file tree, editor, live preview and the graded report

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.

Creating a file, creating a folder by naming a path inside it, and switching between open files

The report

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.

A graded report listing each assertion with expected and actual values

The console

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 workspace console showing formatted log output from the student's own code

Authoring an exercise

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.

Searching the assertion catalogue and picking a check to add to an exercise

Statements are Markdown, with a real editor: toolbar, shortcuts, and a preview that keeps up.

The Markdown statement editor in split mode

Reviewing work

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.

Opening a submission from the review queue, expanding a failed assertion and writing feedback

Two revisions of the same submission, side by side: which tests were fixed, which broke, and the line diff underneath.

Comparing two revisions of one submission

Reporting

Score distribution, the assertions the class fails most often, and a gradebook where a blank is a blank rather than a zero.

Scrolling through the instructor report and then the gradebook

The gradebook: every student against every exercise

The judge fleet

Live workers, their declared judges, dispatch health, and a warning for any judge nothing in the fleet can run.

The judge fleet panel with a registered worker, its capacity and the queue

Both themes, both directions

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.

Switching between the light and dark themes

The workspace in the light theme

On a phone

The dashboard on a phone viewport, with the sidebar collapsed into a drawer

The exercise catalogue on a phone viewport

Every other screen — sign in, catalogue, exercise detail, submissions, progress, leaderboard, management, users, audit log, settings

Sign in

The sign-in screen

The exercise catalogue

The exercise catalogue with difficulty, tags and progress

An exercise

An exercise page with its statement and sample tests

The student dashboard

The student dashboard

A submission

A submission detail page, statement first, then the report and the code

Progress

A student's progress over time

The leaderboard

The leaderboard

Managing exercises

The exercise management table

The review queue

The review queue

Users

User administration

The audit log

The audit log

Instance settings

Instance settings: branding, registration, grading policy and enabled judges

⚖️ How grading works

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
Loading

The seven judges

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 ⚠️ observations only 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.

The assertion catalogue — 128 kinds

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.

Sample tests

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.declaration normalises values. #fff, white and rgb(255,255,255) are the same answer, and shorthands are expanded — a student who writes font: bold 16px/1.5 sans-serif satisfies an assertion on font-weight.
  • js.pattern strips 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 expected and actual. "Failed" teaches nothing; "expected display: flex, found display: block" teaches the lesson in one line.

The rules a score obeys

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-only is 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. combined exists 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. sample and hidden are 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 jobRetryLimit times, 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.

Authoring safety

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.


🧑‍💻 Using it

The workspace

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 learning addEventListener.
  • An element picker. Hover to outline, click to get #app .card > button on 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.

Authoring exercises

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.

📦 Provisioning

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.sh

What 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.

Migrations

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 back

Migrations 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.


🏫 Running a real course

The demo accounts are a convenience, not the product. Everything a course actually needs is here.

People and permissions

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.

Uploads and project exercises

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.

Judges and the worker fleet

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=8

Deployment

Four 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.


🔒 Security model

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

The sandbox, concretely

Trust boundaries: the API decides everything; the browser sandbox and the judge worker may only report observations

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.


🏗️ Architecture

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.

The whole system

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.

The whole system: the SPA and sandbox on the student's machine, the API as the only trusted process, MongoDB and Redis as state, and a judge fleet reporting over HTTP only

Where each part runs

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.

System overview: an ingress in front of the API, MongoDB and GridFS behind it, Redis Streams carrying job ids, and judge workers claiming and reporting over HTTP

Trust boundaries

The API decides everything. The browser sandbox and the judge worker may report observations; neither may report a score.

Trust boundaries: the API decides everything; the browser sandbox and the judge worker may only report observations

Three ways to grade

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.

Three grading paths: static analysis reads the source and is verified; the browser sandbox reports observations and is unverified; a worker runs the suite in a container and is verified

A submission, end to end

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.

A submission: sources stored, static grading on the API, behaviour tests deferred to the browser sandbox, heavier suites to a worker, and every verdict recomputed on the API before it is recorded

One judge job, end to end

Queued, claimed under a lease, heartbeated while it runs, fenced if it is reassigned, and reaped if the worker dies mid-flight.

A judge job from submission through static grading, the queue, the broker, a worker, and back to a recomputed score

Three frames deep

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.

The workspace embeds the sandbox runner in an opaque-origin iframe; the runner embeds the student document in a second sandboxed iframe; only strings travel back up

A request, layer by layer

Every layer a request meets, in order: rate limit, body parse, sanitise, authenticate, authorise, validate, handler, serialise, error handler

Where files live

Upload, archive checks, GridFS, and who is allowed to read one back.

File storage: upload, archive validation, GridFS, and the read authorisation rules

Roles and permissions

Roles are editable rows in a collection. The lockout guards — a last administrator cannot demote or suspend themselves — are not.

Roles and permissions: editable role rows over a fixed permission set, with the administrator lockout guards

How a score is computed

Which results count, which are recorded but not counted, and what is applied at read time rather than baked in.

Scoring: verified results count, unverified ones are recorded but score zero by default, and late penalties and overrides apply at read time

Deployment topologies

The Kubernetes topology: API deployment, worker deployment with an HPA, a network policy denying worker egress, and a reaper cronjob

The Docker Compose topology: three networks, with the judges network internal so workers can reach the API and nothing else

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 by npm run brand, never edited by hand.

Repository layout

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/

No build step

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.

Dependencies

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.


🎨 The front end

Bilingual and bidirectional

Persian and English, with full RTL. Direction is handled with CSS logical properties throughoutmargin-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.

Built from scratch

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

Accessibility

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.

Motion

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.


🚀 Performance

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.

🧪 Testing

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.


📚 Documentation

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

About

Secure automated assessment platform for HTML, CSS and JavaScript. Features live previews, schema-driven exercises, static analysis, browser sandboxing, isolated Docker judge workers, instant feedback, RBAC, internationalization, accessibility, scalable deployment, and instructor tools. Built for secure, scalable front-end education and assessment.

Topics

Resources

Contributing

Security policy

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages