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:

  • GET /api/judge/scores?judge=...: A judge who passes any judge id in the query gets a 403 straight away, and otherwise the filter is built from the token.
  • POST /api/judge/scores: Rejected with 403 unless a JudgeAssignment exists for that exact judge and project.
  • Judging deadline: Scores after judging_close return 400 (Judging is closed).
  • Rubric completeness: Every rubric key must be present and every value must be a finite number from 0 to 5.
  • Rubric weights: Weights must total 100 when an event is created or edited, otherwise the API says so and saves nothing.
  • Submission deadline: Creating, editing and submitting a project all check submissions_close and return 400 once it has passed.
  • Locked projects: A project that is already submitted can not be edited again.
  • Required fields: Submitting needs a title, a summary and a repo URL. Creating needs a valid track and team membership.
  • Duplicates: Unique (event, judge, project) indexes on assignments and scores. A second assignment returns 409.
  • GET /api/export.csv: Organizer and admin only. Judge and participant tokens get 403 from roleAuth.
  • Refresh token reuse: A refresh token that does not match the stored hash revokes the whole session.
  • Public gallery: Only status: submitted projects are returned and the page size is capped at 50.

6. Known Gaps

A write-up that only lists wins is not very useful so here is what I would fix next:

  • express-rate-limit is in package.json but I never wired it into the app. Login and refresh have no rate limit right now.
  • The CSV export escapes quotes but does not neutralize cells starting with =, +, - or @. A judge comment like =HYPERLINK(...) would run when an organizer opens the file in a spreadsheet.
  • Gallery search drops the query straight into a case-insensitive $regex. It works, but it should be escaped before it goes anywhere near a real deployment.
  • The JWT secrets in docker-compose.yml are local demo values on purpose. They have to be changed for anything that is not a laptop.

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:

  • Linux_sys_admin_tool: My zero-dependency Linux audit and security CLI in pure Python that replaced 12 third-party packages with /proc parsing and the standard library. Read the write-up.
  • Linux-Server-Perfomance-stats-script: A lightweight Linux server monitoring script that captures CPU load averages memory usage swap usage and disk I/O stats without requiring heavy enterprise agents.
  • Ad-Nuke: An Android tool and utility script focused on aggressive ad-blocking privacy controls by changing the /etc/hosts file and redirecting ad domains to localhost.
  • Linux-file-integrity-checker: A dedicated Linux file integrity monitoring utility that tracks file modifications and hashes to detect unauthorized tampering on servers.

You can check out the rest of my repos directly on my GitHub: github.com/Bhavishyaa12

Feedback

You can leave a hire note for me :)