Back to Selected Work

Melbourne University Ultimate Club Platform

A Next.js 15 and MongoDB club-management application revisited and hardened around server-side authorization, database-backed roles, automated regression tests and CI.

Sole developer · Built July 2025 · Revisited and hardened August 2026

What the application is

A website for a university Ultimate Frisbee club: announcements, events, a player roster, an alumni directory and club videos on the public side, with a single admin dashboard behind them for managing all of it. It runs on the Next.js 15 App Router with MongoDB through Mongoose, and has two roles — user and admin.

The one piece of real domain modelling is tournament selection. Players are attached to tournaments through a join collection rather than an array on either document, so a selection is its own record with its own constraints.

What changed

  • Authorization moved behind a single choke point: getServerSession is now called in exactly one module, and 23 of the 24 mutating handlers require a session.
  • Roles are read from the database per request rather than trusted from the token, so removing an admin takes effect immediately instead of at token expiry.
  • 73 tests across 3 files, in a repository that had none — including one that discovers routes from the filesystem, so an unguarded endpoint fails CI rather than review.
  • Four update handlers stopped writing whatever the client sent and now name the fields they will write.
  • GitHub Actions runs typecheck, lint, tests and a build on every push, with the build deliberately run without database credentials.

Why I went back

It was built in July 2025 and then left alone. I went back in August 2026 to audit it against its own claims, on the assumption that anyone reading a portfolio can open the repository and check — so the only claims worth making are the ones that survive that.

The audit found two things worth fixing, and both are below. Neither was visible from the outside: the application ran, the pages loaded, and the failure modes were quiet ones. Finding them is the part of this project I would most want to be asked about.

Tracing a “network error” to a URL that never existed

The symptom pointed at the network. The cause was a documented Next.js behaviour being read wrongly: route-group folder names are wrapped in parentheses and do not appear in the URL, so the folder tree and the URL tree are not the same tree. That one misreading produced both of the following.

  • Signup posted to a URL that was never the handler

    The page posted to /api/auth/signup. The handler lives at /api/signup, because the (auth) folder around it is invisible in the URL. The request reached the NextAuth catch-all instead, which answered 400 with a plain-text body, and the client called response.json() unconditionally — so the parse error surfaced as “Network error”, blaming the network for a routing mistake.

  • Middleware guarded a path that does not exist

    The same misreading put the dashboard at /dashboard while the middleware matcher checked /admin, the folder name. Matching a URL that is never requested is indistinguishable from matching nothing at all.

This is one specific documented behaviour being read wrongly in two places, not a general failure of the routing architecture.

Why a valid session carried no role

Fixed in the August 2026 work; what follows is the state before that fix. It is worth reading closely because the mechanism is not guessable from the symptom — the call that caused it looks correct, and typechecks.

  • The call succeeded and still returned nothing useful

    getServerSession() returned a valid session when its options were omitted — the secret still resolved, the cookie still decoded — but NextAuth ran its own default session callback instead of the project's, so role came back undefined. Established by reading the next-auth 4.24.11 source rather than inferred from the symptom.

  • It was the common case, not an outlier

    At the pre-fix commit, 13 of the 16 files that read a session called it without the project's auth options. A role check against an undefined role rejects everyone, including real admins — which also explained a seed route whose admin check had been commented out with a TODO rather than repaired.

  • The type system could not have caught it

    The options parameter is optional, so the correct call and the broken one both typecheck. That ruled out fixing it by convention or by review, and is why the remediation is structural rather than a set of corrected call sites.

Structural authorization redesign

  • Session reading is centralised: getServerSession is called in exactly one module, so its options cannot be omitted at a call site that no longer exists.
  • 23 of the 24 mutating handlers require a session. POST /api/signup is the one anonymous mutating endpoint, by design — without it no account could ever be created.
  • Role is read from the database on each request rather than taken from the JWT claim, so removing an admin takes effect on their next request instead of when their token expires. Anonymous callers skip the query.
  • The admin dashboard has a server-side gate in its route group's layout, redirecting unauthenticated visitors to /login and non-admins to /unauthorized. Middleware also checks the token, as a second layer rather than the deciding one.
  • Alumni contact and employment fields are removed on the server for non-admin callers, so they are absent from the response rather than hidden in the interface.

Mass-assignment remediation

Four update handlers built their database update by spreading the raw request body, which made every field the schema accepts writable by the client — including audit fields, and a publish timestamp the server is supposed to set itself. Each now selects by name the fields it is willing to write.

Naming the fields also settled a question the create path had already answered and the update path had not: what a blank value means. Absent means leave it alone; a deliberately cleared optional field is removed from the document rather than stored as null, because a unique index treats every null as the same value and two records cleared the same way would collide with each other.

A test scans the route files for the original pattern, so the suite fails if that shape returns rather than relying on the next reviewer to notice it.

Findings turned into regression tests and CI

  • 73 tests across 3 files, in a repository that previously had none.
  • The authorization test discovers route files from the filesystem rather than from a hand-written list, so a newly added mutating route is included the moment it exists and has to either reject anonymous callers or be added to an explicit allowlist. That allowlist holds one entry.
  • Reaching the database is treated as a failure inside those tests rather than as a fixture, so a handler that queries before it authorises fails with a named error instead of hanging until the driver times out.
  • A second test scans every route file for the mass-assignment pattern described above.
  • GitHub Actions runs typecheck, lint, the tests and a build on every push and pull request.
  • The CI build runs with MONGODB_URI deliberately unset. Needing a database credential in order to build is a regression this project has had twice, so the pipeline fails on it rather than a deployment discovering it later.

Data model decision

  • Tournament selection is a join collection with a compound unique index

    A selection is a relationship between a tournament, a team and a player, so it is stored as its own document under a unique index across those three fields. Selecting the same player twice for the same tournament and team is rejected by the database rather than by whichever code path happens to run.

    Tradeoff accepted: Reading a roster costs a join rather than reading an array off the tournament document, and the rule lives in an index rather than in application code — so it holds whether or not the caller remembered it, but it is no longer visible in the handler that writes the selection.

What is verified

  • Typecheck, lint, the 73 tests and a production build all pass, and CI runs the same four on every push.
  • The authorization tests were falsified before being trusted: removing a guard from a handler makes the suite fail and name the file, rather than passing quietly.
  • An unauthenticated write to a mutating endpoint was observed returning 401 against a locally running server.
  • 23 of the 24 mutating handlers were enumerated from the source and confirmed to require a session, with POST /api/signup the single deliberate exception.
  • getServerSession resolves to exactly one call site in the codebase, which is what makes the omission that caused the role bug unwritable rather than merely discouraged.
  • The compound unique index on tournamentId, teamId and playerId was read from the schema, so the duplicate-selection rule is enforced by the database rather than asserted in prose.
  • The production build succeeds with no database credentials present, and every API route is emitted as dynamic rather than prerendered.
  • TypeScript runs under strict, with a single explicit any remaining in roughly 15,000 lines — checkable in seconds, unlike a percentage.
  • Supporting structure: a serverless-safe cached Mongoose connection, and a generic useApi/useCrud pair that 13 resource hooks are built on.

Scope of verification

  • No real MongoDB end-to-end verification was performed in the latest evidence audit.
  • Duplicate-key and field-clearing behaviour was verified at the level of the query that gets constructed, not against a real database.
  • Anonymous rejection is tested across every mutating handler, but a signed-in non-admin session against a real database has not been fully exercised.
  • The live deployment was not independently observed during the audit.
  • Historical seeded admin credentials remain in the Git history and would need rotation if they were ever used.
  • Possible stale MongoDB indexes remain an operational check rather than a settled question.