Built a Self-Hostable Hackathon Portal That Runs Offline With One Command
1. What I Built by Hand
I made a hackathon submission and judging portal for the DOGFOOD 2026 spec. It covers T1 which is the core platform (accounts, roles, events, teams, draft projects, deadline-enforced submission and a public gallery) and T2 which is judging (invitations, assignments, a weighted rubric, score isolation between judges, an organizer dashboard, score normalization and a CSV export).
The thing I cared about most was that the rules live in the backend. A hackathon portal is a place where people are competing for prizes so hiding a button in React is not security. If a judge can open the browser console and ask for another judge's scores then the scoring is already broken. So every rule in this write-up is enforced by the API and the frontend is just a nicer way to call it.
It is a MERN stack (MongoDB, Express, React, Node) so this one is not a zero-dependency build like my last write-up. The goal here was different: the whole thing has to run on one machine with no hosted APIs, no hosted database and no OAuth provider.
2. Run It With One Command
docker compose up --build
That starts four containers: MongoDB, a one-shot seed service, the Express backend and the React frontend behind Nginx. The frontend comes up at http://localhost:8080. Startup order is handled with health checks instead of sleep commands: seed waits for Mongo to be healthy, the backend waits for seed to complete successfully and the frontend waits for the backend health check on /api/health.
Seeding uses fixed IDs and upserts so running docker compose run --rm seed again does not create duplicate fixture records. If the official fixture file is missing the seed falls back to a small deterministic demo dataset so the app can still be shown on a laptop with nothing else.
3. Acceptance Proof
The hackathon ships its own checker called run.py. It is standard library only (urllib, argparse, re) and it even carries a tiny TOML parser for Pythons older than 3.11, so it ties back to the zero-dependency idea from my other write-up. I ran it against the live stack and saved the output to acceptance-report.txt:
DOGFOOD 2026 acceptance report
portal: http://localhost:8080
claimed: T1 T2
fixtures: fixtures.json
T1 gallery is public ................. PASS
T1 project from fixtures shown ....... PASS
T1 closed event refuses submissions .. PASS
T2 judge sees own scores ............. PASS
T2 judge cannot see peer scores ...... PASS
T2 participant blocked ............... PASS
T2 csv export works .................. PASS
claimed T1 T2, verified T1 T2
Seven checks pass for the two tiers I claimed. The two I like most are judge cannot see peer scores and closed event refuses submissions because both prove a rule is enforced by the server and not by the UI.
4. What I Reached For and Why
Here is every major piece of the stack and the decision behind it:
| Tool | Used For | Why and How |
|---|---|---|
React 19 + vite |
Frontend SPA | Gallery, dashboards, judge and organizer screens. Vite builds it and an Nginx container serves the static output so there is no Node process running in production for the frontend. |
express 5 |
REST API | Every route goes through one auth middleware and then roleAuth(...). Keeping those two tiny functions in one file made it easy to check that no route was left open by accident. |
MongoDB 8 + mongoose |
Database | Documents fit the fixture shape (event, tracks, rubric inside one doc). Unique compound indexes do the heavy lifting for duplicate assignments and duplicate scores. |
bcryptjs |
Password hashing | Cost factor 12. The pure JS version means the Docker build never needs a C toolchain. |
jsonwebtoken + cookie-parser |
Sessions | 10 minute access JWT and a 7 day refresh JWT in an HTTP-only SameSite=strict cookie. The refresh token is rotated on every use. |
Node crypto |
Token hashing and invite tokens | Refresh tokens are stored as SHA-256 hashes, invite tokens come from crypto.randomBytes(24). No uuid package needed. |
| Docker Compose | One command run | Four services: mongo, a one-shot seed, backend and frontend. depends_on with health checks decides the startup order, so seed never runs before Mongo is ready. |
Python stdlib (run.py) |
Acceptance checker | Official checker is standard library only. It ships its own small TOML parser as a fallback for Pythons older than 3.11 where tomllib does not exist. |
Rotating Refresh Tokens
Sessions are a short-lived access token plus a refresh token that is stored only as a SHA-256 hash in the session collection, never raw. Every refresh issues a brand new refresh token and replaces the stored hash. If somebody replays an old token the hash does not match and the whole session gets revoked. Here is the actual hashing helper and the rotation chunk (trimmed a little):
const hashRefreshToken = (token) => {
return crypto.createHash("sha256").update(token).digest("hex");
};
const incomingHash = hashRefreshToken(refreshToken);
if (incomingHash !== session.refreshToken) {
session.revoked = true; // reuse detected, kill the whole session
await session.save();
clearRefreshCookie(res);
return res.status(403).json({
success: false,
message: "Refresh token reuse detected",
});
}
// otherwise rotate: new refresh token, new hash, new expiry
session.refreshToken = hashRefreshToken(newRefreshToken);
session.expiresAt = new Date(Date.now() + REFRESH_TOKEN_EXPIRES);
await session.save();
Judge Isolation in Code
This is the most important piece of the project. The route only lets the judge role in, and then the controller ignores any identity the client sends and uses the one inside the verified token:
router.get("/scores", auth, roleAuth("judge"), c.getScores);
const getScores = async (req, res) => {
if (req.query.judge) {
return res.status(403).json({
success: false,
message: "Judges cannot request another judge's scores",
});
}
// identity comes from the verified token, never from the client
const filter = { judge: req.user.userId };
const scores = await Score.find(filter).lean();
res.json({ success: true, scores });
};
Submitting is the same idea. The judge has to have a JudgeAssignment for that project or the request gets a 403, and the final raw score is the weighted average sum(score x weight / 100) which always lands between 0 and 5.
Normalizing Judges Who Score Differently
Some judges give everybody a 4 and some judges are harsh, so a raw average is not fair. For every judge I take the mean and the population standard deviation of their raw scores, turn each score into a z-score and map it to 2.5 + 0.75 * z clamped to 0 to 5. That puts a judge's average project at 2.5 and one standard deviation above it at 3.25. A judge with zero variance gets 2.5 on everything because there is nothing relative to normalize:
for (const rows of byJudge.values()) {
const vals = rows.map((x) => x.raw_total);
const mean = vals.reduce((a, b) => a + b, 0) / vals.length;
const variance =
vals.reduce((a, b) => a + (b - mean) ** 2, 0) / vals.length;
const sd = Math.sqrt(variance);
for (const s of rows) {
s.normalized_total =
sd === 0
? 2.5
: Math.max(0, Math.min(5, 2.5 + 0.75 * ((s.raw_total - mean) / sd)));
}
}
Normalized values are computed on the fly for the dashboard and the CSV and I never overwrite the raw score in the database, so the audit trail keeps exactly what each judge submitted.
5. The 12 Rules the Backend Enforces
Here is what the API actually rejects or guarantees when it is hit directly, with no frontend involved:
6. Known Gaps
A write-up that only lists wins is not very useful so here is what I would fix next:
Fixture IDs are kept as strings while MongoDB ObjectIds are used for the internal relationships. That keeps the official fixtures importable without forcing their shape onto my schema, and it is the tradeoff I would make again.
7. Other Projects & Work
Apart from this hackathon portal here are some of my other open-source projects built for Linux environments and Android security:
You can check out the rest of my repos directly on my GitHub: github.com/Bhavishyaa12