Skip to content
Len3hqPublic

About

No description, website, or topics provided.

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

50 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Evenhand

A self-hostable hackathon submission and judging portal whose rankings come with receipts. Built for DOGFOOD 2026: "build the platform that will judge you".

  • Teams submit projects to a public gallery until a deadline that actually holds.
  • Judges score against a weighted rubric and cannot see each other's work. That is enforced by the API, not hidden in the UI.
  • Organisers follow progress, export CSV, and publish rankings corrected for harsh and generous judges, each with a receipt and an audit log behind every step.

Everything runs on your own machine, with the network off. No accounts, no hosted services.

At a glance
Tiers claimed T1, T2 and T3. T1 and T2 are verified by the organisers' checker: 7 / 7 PASS. The checker has no T3 checks, so its report notes T3 as not verified by it; T3 (comments, community voting with hidden results) is verified by our own end-to-end and browser tests, listed under Verified
Bonus challenges Normalization Proof (docs/proof/normalization.md, regenerated and checked by a test), Threat Model (JUDGING.md), API First (OpenAPI 3, Swagger UI at /api/docs). Pairwise Mode is not attempted: the fixtures hold ratings, not comparisons
Not built Webhooks, certificates, signed records and an embeddable widget (T4)
Read next ARCHITECTURE.md · DATA-MODEL.md · JUDGING.md · DECISIONS.md

Quick start

You need Docker (Docker Desktop, OrbStack or Docker Engine with Compose v2). Nothing else.

docker compose up

The first run builds the images (about 3–4 minutes, and the only step that needs the internet). After that, the portal starts in about 15 seconds, fully offline:

What Where
Portal http://localhost:8080
Public gallery http://localhost:8080/projects
API docs (OpenAPI / Swagger UI) http://localhost:8080/api/docs

On every start the API applies database migrations, imports the organisers' fixtures.json (41 projects, 30 judges, 8 tracks, 126 reviews; safe to repeat) and prints the test logins:

seeded. test logins (DEMO_MODE=true: demo only, never use in production):
  organizer    Authorization: Bearer dev-organizer-7f2a    (organizer@evenhand.local)
  judge_a      Authorization: Bearer dev-judge-a-91bc      (jdg_24 Diego Herrera)
  judge_b      Authorization: Bearer dev-judge-b-44de      (jdg_26 Jonas Vogel)
  participant  Authorization: Bearer dev-participant-2e88  (priya1@example.org)

Every seeded account can also log in through the web UI with the password evenhand-demo, e.g. organizer@evenhand.local, diego.herrera@example.org (judge), priya1@example.org (participant) or admin@evenhand.local.

Demo mode. The fixed tokens and shared password exist so the acceptance checker and a first-time visitor can get in straight away. They only work while DEMO_MODE=true (the compose default). Set DEMO_MODE: 'false' in docker-compose.yml for a real event: the demo tokens are then refused.

Try it with curl:

curl localhost:8080/api/projects                                                  # public gallery
curl -H 'Authorization: Bearer dev-judge-a-91bc' localhost:8080/api/judge/scores  # a judge's own scores
curl -H 'Authorization: Bearer dev-judge-b-44de' localhost:8080/api/judges/jdg_24/scores  # 403: not yours

To start again from an empty database: docker compose down -v && docker compose up.

A ten-minute tour

Log in at http://localhost:8080/login with the password evenhand-demo.

  1. As a visitor, open the gallery: search, filter by event and track, and open a project to see how it is judged.
  2. As a participant (priya1@example.org): My teams → the Demo Team in the open Evenhand Demo Hack → start a submission, answer the organisers' questions, add images, submit. It appears in the gallery; the draft never did.
  3. As an organiser (organizer@evenhand.local): Organise → Sample Hack 2026, the organisers' fixture event:
    • settings: tracks, prizes, the weighted rubric, questions, judge invite links and Run assignment;
    • Judging progress: who has finished, and the fixtures' flat judge (jdg_07, 4/4/4 on everything) flagged;
    • Entries and duplicates: the planted Dry Harbour duplicate, with whose reviews would move before you confirm (and an Undo);
    • Results: run a ranking and read its receipt (normalized scores ± uncertainty, tie groups, a reason per project, hashes), then publish it to the public results page;
    • Community vote: choose who may vote (accounts, emailed links or a shared link), a window and votes per voter; vote yourself at /events/sample-hack-2026/vote, watch the hidden tally, and publish once the vote has closed;
    • Audit trail: every step above as a sentence.
  4. As a judge (diego.herrera@example.org, fixture judge jdg_24): Judging lists his 11 submitted reviews; new projects appear when an organiser runs assignment. Score with the keyboard: a digit marks the highlighted criterion, [ and ] move through the queue.
  5. As the admin (admin@evenhand.local): Admin → the platform trail of logins, failed logins, tokens and rate-limit refusals, with addresses.
  6. With curl, as above: judge B asking for judge A's scores gets 403, before the database is even asked whether judge A exists.

Acceptance report

npm run acceptance            # builds + starts the stack, runs the organisers' run.py
SKIP_UP=1 npm run acceptance  # against a stack that is already running

The result is written to acceptance-report.txt. The script downloads the official run.py into .cache/ (it isn't ours, so it isn't committed) and reuses the cached copy when offline. Routes and test headers are in .dogfood.toml.

T3: what the checker cannot check, and how we check it

run.py has checks for T1 and T2 only, so its report lists our T3 claim as not verified by it. Each T3 requirement from the brief is instead checked by a named test on every CI run and in the offline drill, and can be tried in the browser:

T3 requirement Checked by Try it
Community voting with configurable access: authenticated, email-gated, or an open link voting.e2e-spec.ts: voting with an account, voting with personal links for listed emails, voting through a shared link; browser: run a community vote: vote, close it, publish it, and a visitor sees it Organiser: event → Community vote. Voters: /events/:event/vote, or the personal link
Results hidden from everyone except organisers while voting is open keeps the tally from everyone but organisers until results are published; stops counting when the vote closes, then publishes the results /api/events/:event/votes/results is 404 until published
Randomised project order on ballots gives each voter an order of their own, the same each time they look (seeded from the ballot, so reproducible) Two voters see two orders
Anti-abuse: rate limits refuses votes over RATE_LIMIT_VOTE_PER_MIN with 429; refuses posts over RATE_LIMIT_COMMENT_PER_MIN with 429
Anti-abuse: duplicate detection One ballot per account or personal link, one vote per project, a fixed number of votes (row-locked); no votes for your own team; the shared link gives a browser its own ballot back (gives a browser that already took a ballot the same one back), and behind a proxy that reports addresses counts ballots taken from one address and shows their votes apart before publishing (flags ballots taken by one address more than once, only when a proxy reports addresses) Organiser's vote panel
Anti-abuse: an audit trail an organiser can read writes every vote and withdrawal to the audit trail; audits link votes without naming the voter; comments: audits hiding with its reason, and restoring. The trail is hash-chained and tamper-checked Organiser: event → Audit trail
Comments on gallery projects comments.e2e-spec.ts: posting, length limits, not on non-public projects, moderation (hide with a reason, restore), audit; browser: Ann comments on the project, and a visitor reads it, hide a comment with a reason, then restore it Any public project page

Verified

Each claim below is checked by a command anyone can run, not asserted. Numbers are from the last run and are updated when they change.

Claim Checked by Result
The organisers' checker passes npm run acceptance (official run.py against docker compose up) 7 / 7 PASS
Roles are enforced by the API, not the UI tests/api/isolation.e2e-spec.ts: 87 of the API's 93 operations × 7 roles; the judge console's writes are checked per judge in reviews.e2e-spec.ts. The rest are the health check and login, registration and logout, which anyone may call 90 rows × 7 roles, 630 expected status codes
A judge is refused before any lookup, so refusals leak nothing Same file: a real and a non-existent judge get the same 403 Pass
The deadline holds to the millisecond, on every write tests/api/submissions.e2e-spec.ts: refused at the closing instant, 1 ms before gets through; drafts.e2e-spec.ts: edit and submit refused after the close and before the opening Pass
The audit log cannot be edited or deleted, even with SQL tests/api/constraints.e2e-spec.ts: UPDATE, DELETE, TRUNCATE refused Pass
Tampering with the audit log in the database is detected tests/api/audit-chain.e2e-spec.ts: with the triggers switched off, an edited entry, a deleted one and the newest ones cut off are each found by GET /api/audit/verify; 25 simultaneous writes still form one chain
An organiser can read the audit trail without knowing the schema tests/api/audit-log.e2e-spec.ts: every entry has a sentence naming who and what; no tokens, hashes or IP addresses reach organisers Pass
A fresh copy runs and passes every check with the network cut npm run drill (tests/offline/drill.sh): clones HEAD, builds, then starts it on empty volumes with every network internal; an internet request that works before the cut fails from the api and the web container after it; then run.py and every browser check run inside that network 7 / 7 and 54 / 54 with no route out
A backup restores exactly, and keeps the audit log append-only Backup and restore commands below, restored into a second database and compared Row counts identical; UPDATE on audit refused
An event moves out and back in unchanged tests/api/transfer.e2e-spec.ts: exporting evt_01 reproduces the published fixtures.json; export → cli import into an empty database → export is byte-identical Pass
Abuse is limited, and the limits are real tests/api/security.e2e-spec.ts: the 4th export over a limit of 3 is refused and audited once; judge writes over their limit are refused; after too many bad tokens further bad ones are refused for the minute while a valid token still works; callers sharing an address keep their own allowances, and logins count per email (auth.e2e-spec.ts); a stale cookie on public pages is not counted Pass
The planted duplicate is resolved without losing or double-counting a review tests/api/integrity.e2e-spec.ts: on the fixtures' Dry Harbour pair, confirming leaves prj_41 with 6 counted reviews (jdg_01's and jdg_12's move across; jdg_19, 21 and 26's copies are set aside), prj_07 leaves the gallery, judging and judges' queues; undoing restores every assignment and review exactly; dismissing one team's two entries is refused; a disqualified entry leaves every judging view, its judge can no longer write, the team sees the reason, and reinstating brings it back Pass
An uploaded image carries nothing but its pixels, and is as private as its project tests/api/images.e2e-spec.ts: a JPEG with EXIF and a script appended is stored as WebP without either; HTML named .png, SVG, GIF and truncated files are refused; 8 MB and 40 megapixels are enforced (a 144-megapixel bomb is refused before decoding); strangers and late teams are refused before the upload is read; a draft's images are 401 / 403 to others, public once submitted, private again when disqualified; at most 6; reorder and removal (files included) audited Pass
Comments are moderated and cannot be abused quietly tests/api/comments.e2e-spec.ts: visitors read and cannot post; anyone logged in posts on a public project, never on a draft; authors appear by name only; only the event's organisers hide (with a reason) and restore, and others are refused before any lookup; hidden comments never reach visitors; posts over the limit get 429; every post, hide and restore is a sentence in the audit trail Pass
Community voting keeps to its rules and hides its results tests/api/voting.e2e-spec.ts: only the event's organisers set up a vote, refused before any lookup; votes count only inside the window by the server clock, at most one per project and up to the voter's limit, never for your own team; each ballot has its own order, the same every time; the mode and vote count are fixed once voting starts, and a published vote is final; the tally is for organisers only, publishing is refused before the vote closes, and the public results name no voter; personal links work only for their hash, one per email; a new shared link retires the old one; a browser that follows the shared link again gets its own ballot back; ballots taken by one address more than once are flagged for organisers only when a proxy reports addresses, never on the portal's own container address; votes over the limit get 429; every step is a sentence in the audit trail; the database refuses a backwards window, a bad vote count and a ballot with no voter or two Pass
API tokens are secret, revocable and never recorded in clear tests/api/tokens.e2e-spec.ts: a token issued with cli tokens create acts as its account and is stored only as a hash; neither the list nor the audit trail shows it; a revoked token is refused at once; --demo shuts the public demo tokens and a restart does not bring them back Pass
Normalization recovers a known truth and the published proof is current normalize.spec.ts: mean Spearman ρ > 0.9 over 20 seeded synthetic events, beating raw means and z-scores; leave-one-out checked against refits; proof.spec.ts regenerates the proof and compares it byte for byte Pass
People can use the portal from a browser npm run test:ui (tests/ui): a real Chromium registers, logs in and out (and is not sent off-site by a crafted ?next=), and an admin finds that session's failed login, with its address, in the platform trail, which a participant is refused; a visitor searches and filters the gallery by event and track, sees results follow as they type (focus and text kept) and sees how a project is judged; two new people form a team through an invite link, draft, meet the API's "summary needed" message, answer the demo event's two questions and submit (the private answer stays out of the gallery), add an image that becomes the cover (a file that is not an image is refused), see its thumbnail load in the gallery, find the project in the gallery and keep editing, then one of them accepts a judge invitation for another event, is no longer offered a team there, and judges the project assigned to him (a draft that survives a reload, a refused incomplete submit, the last mark typed from the keyboard, then a final review); an organiser creates an event, moves its deadline, manages tracks, prizes, its own questions (added, reordered, required and public settings kept after a reload), a weighted rubric (shares shown live; a locked rubric offers only weights and names), organisers and a judge invite link, runs assignment on the fixture event, watches the live progress dashboard flag the fixtures' flat judge and refresh by itself, then ranks and publishes the fixture event and a visitor reads the results, confirms the fixtures' planted duplicate (the older copy leaves the gallery) and undoes it, runs a community vote on the fixture event (sets it up, votes, sees the tally, closes and publishes it, and a visitor finds the results from the gallery), gives a visitor the same ballot when they follow a shared voting link twice, disqualifies an entry with a reason and reinstates it (the API refuses the last organiser's removal) and reads it all in the audit trail 54 / 54 steps pass, with no Content-Security-Policy violation
Private answers stay private, and teams' answers are never lost tests/api/questions.e2e-spec.ts: the gallery shows public answers only while the judge, team and organisers see all; an answered question cannot be removed or made public; nothing becomes required that a submitted entry left empty; a submitted entry cannot be emptied again Pass
Everything else npm run check: format, lint, typecheck, unit and end-to-end tests 125 unit + 937 end-to-end tests pass

The REST API is described by an OpenAPI 3 document, src/api/openapi.json, served with Swagger UI at http://localhost:8080/api/docs (works offline).

What is built

Status on Mon 28 Sep (UTC). Each row is built and tested as its status says; nothing here is a plan.

Area Status
One-command start, seeded from fixtures.json, runs with no internet route (offline drill) ✅ done, verified
Acceptance checker (T1 + T2, 7 checks) ✅ 7/7 pass
Auth: browser sessions (httpOnly cookie + CSRF Origin check) and bearer tokens; register, log in, log out pages ✅ API + pages, e2e- and browser-tested
Roles per event (participant, judge, organizer) + platform admin; deny-first 403s; organisers appointed and removed per event (never the last one, never someone competing in or judging it) ✅ API + settings page, isolation matrix e2e-tested
Deadline enforcement: opening and closing time, server clock, both boundaries tested ✅ every write: submission create / edit / submit, teams, invites
Public gallery with search, event and track filters, tag filter and pagination; project pages ✅ API + UI, browser-tested
Judges' own scores, peer isolation, scores CSV export ✅
Append-only, hash-chained audit log (database triggers; tamper check on the audit pages, GET /api/audit/verify, cli verify-audit), duplicate detection on import; duplicate confirm / undo (reviews merged, nobody counted twice) and disqualification with a reason ✅
Readable audit trail: one sentence per entry, filters, CSV export; platform trail for admins (logins, failed logins, accounts, API tokens, rate-limit refusals, with addresses) ✅ API + organiser page (/organizer/events/:event/audit) + admin page (/admin/audit), browser-tested
Rate limits: login/register, failed token checks, exports, judge writes (429, audited for admins); Content-Security-Policy with per-request nonces ✅ e2e- and browser-tested
First admin (create-admin), password reset (reset-password), API tokens (tokens), backup and restore ✅ verified
Event export (fixtures.json shape + extras) and cli import; round trip tested ✅ API and CLI
Event creation and editing with dates, tracks and prizes ✅ API + organiser pages (/organizer), browser-tested
Teams and invite links (hashed tokens, expiry, use limit) ✅ API + pages (/teams, /invites/:token), browser-tested
Draft editing and submitting (edit until the deadline, drafts private) ✅ API + submission form (/submissions/:id), browser-tested
Organiser-defined questions: required or optional, public or private; answers saved with drafts, required before submitting, shown to judges, in the gallery (public ones only), the submissions CSV and the event export ✅ API + settings page + submission form, e2e-tested
Image uploads (thumbnail, image gallery): up to 6 per project, re-encoded to WebP with their metadata removed, the first as the cover; as private as the project ✅ API + submission page, gallery, project and judge pages; e2e- and browser-tested, offline
Weighted rubric the organiser configures (public, shown on project pages; locked for structure once reviews are final) ✅ API + settings page, e2e- and browser-tested
Judge invitation by link, per track (conflict of interest refused; progress per judge) ✅ API + settings page + /judge-invites/:token, e2e- and browser-tested
Judge assignment: least-loaded, track-matched, conflict-free, seeded and reproducible; tops up only ✅ engine (unit-tested on the fixtures) + API + settings page
Judge console: queue with progress, one project per page, keyboard scoring, autosaved drafts, final reviews, closes with judging ✅ API + /judging, e2e- and browser-tested
Live progress dashboard (per judge and project, refreshes every 10 s, flags a judge who gave every project the same marks); teams, submissions and assignments CSVs ✅ API + /organizer/events/:event/progress, e2e- and browser-tested
Cross-judge normalization: joint ridge model, λ by leave-one-out, uncertainty and tie groups; the Normalization Proof on the fixtures ✅ engine, unit-tested; proof checked by a test
Ranking runs with a receipt (reasons, uncertainty, tie groups, input and result hashes), publish (refused when stale), public results hidden until published, results CSV ✅ API + /organizer/events/:event/results + /events/:event/results, e2e- and browser-tested
Comments on gallery projects (T3): anyone logged in; organisers hide with a reason and restore; rate limited and audited ✅ API + project page, e2e- and browser-tested
Community voting (T3): by account, by emailed personal link or by shared link; a few votes each, one per project; a ballot order of its own for every voter; tally hidden until the vote closes and is published; own-team votes refused; rate limited, a shared-link ballot remembered per browser, audited ✅ API + organiser page (/organizer/events/:event/voting), ballots (/events/:event/vote, /vote/:token) and public page (/events/:event/vote/results); e2e- and browser-tested
Webhooks, certificates, signed records, embeddable widget (T4) ❌ not built

Limitations

Written down so a reviewer does not have to find them. Everything in the table above is built; these are the limits of the design as it stands.

  • No email. Nothing is sent: team invite links are copied and shared by hand, and there is no password-reset email. An operator resets a password with create-admin --reset-password (below). Sending mail would mean an SMTP server, which the offline rule excludes from the default setup.
  • Teams can only grow, until the deadline. Each invite link admits at most 4 people and expires after 7 days, but a team can issue more links, and there is no team-size limit per event. Leaving a team, removing a member and revoking an invite link early are not built.
  • A final review is final. Neither the judge nor an organiser can reopen it; a judge who made a mistake tells the organisers, who can see it in the scores export and the audit trail.
  • Exports carry submitted work only. Drafts and unfinished reviews are not exported, and imported accounts have no password until an operator runs cli reset-password. There is no import through the web yet; it runs from the server shell.
  • Demo mode is insecure on purpose. With DEMO_MODE=true (the compose default) the four test tokens and the shared password are public, so the checker and a first-time visitor can get in. Turn it off for a real event.
  • No TLS in the box. The portal serves plain HTTP on :8080. Put a reverse proxy that terminates TLS in front of it for anything beyond a laptop.
  • Anonymous visitors share one address behind the bundled proxy. Signed-in callers and logins have limits of their own (ADR), but anonymous gallery views share RATE_LIMIT_DEFAULT_PER_MIN per route, and ballots taken from a shared voting link share RATE_LIMIT_VOTE_PER_MIN; raise them for a large audience. Next.js also passes on a client-supplied X-Forwarded-For, so a reverse proxy in front should overwrite it (threat model). Without one, the admin trail records the address the api sees, which on a laptop is Docker's internal network.
  • One machine. One Postgres, one API and one web container. Sized for a hackathon of hundreds of people, not a platform of many concurrent events.
  • A database superuser can rewrite history, but not quietly. The audit log is append-only through triggers and hash-chained by the database, so editing, deleting or cutting off entries directly in Postgres is detected by the tamper check. Someone with full access could still recompute the whole chain after their change; published results anchor the chain's head at publication, and the public results page checks, every time it is opened, that this anchor is still in the log.
  • Images stay on the machine that received them. They are not in the event export (the fixtures.json shape has no field for them); back up the uploads volume with the database. Uploads are re-encoded, so they are slightly compressed, and animated images are not accepted.
  • A shared voting link cannot tell people apart. Following it again in the same browser returns the same ballot, but a private window, cleared cookies or another device gets a new one. The portal as shipped cannot see voters' addresses (every browser reaches the API through the web container), so it cannot flag repeats, and its page says so; behind a reverse proxy that reports addresses, ballots from one address are counted apart for organisers. Use accounts or emailed links for a vote with a prize. A suspicious vote cannot be deleted, only left unpublished.
  • Decide suspected duplicates before opening a vote. Ballots list every public project, so both copies of an undecided duplicate appear and can split votes. Confirm or dismiss duplicates first (publishing rankings is refused while any are pending).
  • Times are UTC. Deadlines are stored and enforced in UTC by the server's clock; a client's clock is never trusted.

The reasoning behind each design choice, and the designs we rejected, are in DECISIONS.md.

Running a real event

The defaults are for a demo. For a real event, edit the api and db sections of docker-compose.yml:

  1. DEMO_MODE: 'false': the public test tokens and the shared demo password stop working.
  2. SEED_FIXTURES: 'false': the organisers' sample event is not imported.
  3. Replace the database password evenhand in both POSTGRES_PASSWORD and DATABASE_URL.
  4. ALLOWED_ORIGINS: the address people will use, e.g. https://hack.example.org.
  5. Put a TLS-terminating reverse proxy in front of port 8080 (see Limitations).

Then start it and create the first admin (a platform admin has organiser rights on every event):

docker compose up -d
docker compose exec api node dist/cli/cli.js create-admin you@example.org --name "Your Name"

To bring in an event from another portal (any file in the fixtures.json shape, including an Evenhand export), see DATA-MODEL.md → Import; people it creates get their passwords from cli reset-password <email>.

The password is printed once; store it. Run the same command on an existing account to make it an admin (its password is kept), or add --reset-password to issue a new one. Every grant is written to the audit log.

Scripts and integrations use bearer tokens. A token acts with its account's roles, is shown once and is stored only as a hash:

docker compose exec api node dist/cli/cli.js tokens create you@example.org --label nightly-export
docker compose exec api node dist/cli/cli.js tokens list              # state of every token, never the secret
docker compose exec api node dist/cli/cli.js tokens revoke <id>       # refused from the next request on
docker compose exec api node dist/cli/cli.js tokens revoke --demo     # shut the public demo tokens for good

DEMO_MODE: 'false' already refuses the demo tokens; revoke --demo is for a stack that keeps demo mode on (a rehearsal) but must not accept the published tokens. Issuing and revoking are audited by label.

Checking the audit log for tampering

The database hash-chains every audit entry. The audit pages show the result of the tamper check, and it also runs from the shell (exit code 1 if the log was changed):

docker compose exec api node dist/cli/cli.js verify-audit

Note the head hash it prints from time to time (or after publishing results): a later check that does not reach the same head means entries were removed.

Backup and restore

The whole portal state is the Postgres database. Back it up while running:

docker compose exec -T db pg_dump -U evenhand -d evenhand --format=custom > evenhand.dump

Restore onto a fresh stack (this wipes the current data):

docker compose down -v            # deletes the database volume
docker compose up -d --wait db
docker compose exec -T db pg_restore -U evenhand -d evenhand --no-owner < evenhand.dump
docker compose up -d

The restored database keeps its migration history and its append-only, hash-chained audit log (the tamper check still passes after a restore). Project images live in the uploads volume, not the database; back it up too:

docker compose exec -T api tar -C /app/uploads -cf - . > evenhand-uploads.tar

and restore it into a running stack with docker compose exec -T api tar -C /app/uploads -xf - < evenhand-uploads.tar.

Development

Requirements: Node 24 (nvm use), npm 10+, Docker.

cp .env.example .env   # local settings (Postgres on host port 5433)
npm install
npm run dev            # Postgres in Docker; engine, api (:3001) and web (:3000) with hot reload

npm run dev migrates and seeds the database on start. Open http://localhost:3000.

Command What it does
npm run dev Postgres in Docker + engine, api and web in watch mode
npm run check Format check, lint, typecheck, unit tests, e2e tests. Must pass before every commit.
npm test Unit tests (no database)
npm run test:e2e API end-to-end tests against a throwaway evenhand_test database
npm run gen:api Regenerate src/api/openapi.json and the web's typed client
npm run cli -- seed Import fixtures + demo data (idempotent)
npm run db:reset Drop and recreate the dev database, then seed
npm run acceptance Run the organisers' checker against the Docker stack
npm run test:ui Every page, clicked through in a real browser against the running stack
npm run drill Offline drill: fresh clone, empty database, network cut, then the checker and browser

How to contribute (layout, rules, review, ownership) is in CONTRIBUTING.md.

Repository layout

src/api             NestJS API: every permission, deadline and audit rule
src/web             Next.js UI: a client of the API, never the security boundary
src/judging-engine  Pure TypeScript judging maths (no I/O), unit-tested on its own
tests/              End-to-end (tests/api), browser (tests/ui), acceptance and offline drill
data/fixtures.json  The organisers' fixture data, loaded on every boot
docker/             Dockerfiles and the api entrypoint
DECISIONS.md        Index of the decision records, and the designs we rejected
docs/decisions/     Architecture decision records (one file per decision)
docs/proof/         The Normalization Proof, generated from the fixtures and checked by a test
docs/planning/      The plans written before the build, kept as they were

Design: ARCHITECTURE.md · Schema, import and export: DATA-MODEL.md · Scoring, normalization and threat model: JUDGING.md · Decisions: DECISIONS.md

Troubleshooting

Symptom Fix
port is already allocated for 8080 Something else uses 8080. Stop it, or change '8080:8080' to e.g. '8081:8080' in docker-compose.yml (and base_url in .dogfood.toml).
Old data or a failed migration after pulling changes docker compose down -v && docker compose up --build (wipes the local database).
npm run dev cannot reach Postgres npm run db:up. Check that nothing else uses host port 5433.
Changed code but Docker shows the old version docker compose up --build
Project images missing after a restore Images live in the uploads volume, not the database: restore it too (Backup and restore).

AI use

Built with Claude Code. The humans on the team own the design decisions in DECISIONS.md and docs/planning, review every change, and can defend the schema and the maths in writing. AI output is reviewed and tested like any other code.

License

MIT. Image processing uses sharp (Apache-2.0), which bundles libvips (LGPL-3.0-or-later), used unmodified as a separate library. The bundled fonts, Inter, Manrope and JetBrains Mono, are under the SIL Open Font License 1.1.

About

No description, website, or topics provided.

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages