Built by Ryan and Hanako.
The Purdue Undergraduate Student Board's technical projects site. It carries two boards plus directories of technical clubs and campus competitions.
Built as a single Vercel deployment — a static React frontend and a set of serverless functions that share one origin. It is a sibling of the USB Research Resources site and deliberately shares its design system, its auth flow, and its backend shape.
They answer different questions, so they are separate collections with deliberately different schemas. Nothing is shared between them but the category vocabulary.
Showcase (/projects) |
Open projects (/open-projects) |
|
|---|---|---|
| For | work you have finished | work you are still building |
| Purpose | show it off | find people to join you |
| Collection | projects |
open_projects |
| Validated by | backend/lib/projectInput.js |
backend/lib/openProjectInput.js |
| Moderated by | prompts/moderation.md |
prompts/openProjectModeration.md |
| Has an image | yes, required | no — there's often nothing to screenshot yet |
| Who worked on it | members[] (who built it) |
rolesNeeded (who is still missing) |
| Technologies | tags[] |
techStack[] |
| Link | links, required |
repoUrl, optional |
| Also carries | status |
requirements, timeCommitment, teamSize, deadline, manager, contactEmail |
| Per-account cap | 10 | 3 |
The moderation prompts differ because the bar differs: the showcase asks "is this a real project somebody built", the open board asks "is this a real project genuinely looking for people" — and must not penalise a listing for the project not existing yet, which is the entire point of it.
api/ Vercel serverless functions (one file = one function)
auth/[action].js signup, verify-email, resend-code, login, me, password reset
- all seven in one function, dispatched on the path segment
submit.js POST - post to the showcase
projects/index.js GET - the public showcase
projects/mine.js GET - your own showcase posts
projects/[id].js PUT - edit, DELETE - remove (owner or admin)
projects/[id]/image.js GET - one project's image bytes
open-projects/index.js GET - the public open board, POST - create a listing
open-projects/mine.js GET - your own listings
open-projects/[id].js PUT/DELETE (owner or admin)
backend/ Workspace holding the modules those functions share
lib/ env, db, auth, mailer, http, rate limiting, validation, moderation
prompts/ The LLM moderators' system prompts, editable without touching code
scripts/ One-off and scheduled maintenance jobs
frontend/ Vite + React 19 + Tailwind v4 single-page app
src/components/ui/ The shared design system (Button, Card, Field, ModalShell, ...)
src/context/ Auth provider and the useAuth hook
Every file under api/ becomes its own serverless function, and Vercel's Hobby plan caps a
deployment at 12. That is why the seven auth endpoints share api/auth/[action].js instead of
getting a file each — as separate files the project came to 15 and the build was rejected
outright. It currently sits at 9, so there is room for three more before that has to be
revisited. The public URLs are unaffected either way.
Posting to either board requires a verified account; browsing both is public. The flow matches
Research Resources: a @purdue.edu address, a 6-digit code emailed via SMTP, and a self-serve
password reset on the same mechanism.
A few things that are load-bearing rather than incidental:
- Codes are stored hashed, never in plaintext, and compared in constant time. Anyone with
read access to the
userscollection could otherwise read a live code and take over an account mid-flow. - The JWT payload does not carry
isAdmin. Privileges are re-read from Mongo on every request that needs them, so demoting or deleting an account takes effect immediately rather than whenever the old token happened to expire. - Login answers identically for an unknown email and a wrong password, and compares against a dummy hash when no account exists so the timing matches too. Password reset likewise never reveals whether an address is registered.
- Ownership is checked server-side on every mutation. The public board omits
userIdentirely, so which cards offer Edit/Delete is a UI convenience, not the control.
npm install
cp .env.example .env # then fill it in - see belowSame two commands as the Research Resources site — the backend from the repo root, the frontend from its own workspace:
npm run api # terminal 1, repo root: vercel dev, API on :3000
cd frontend && npm run dev # terminal 2: vite, site on :5173, proxies /api to :3000Then open http://localhost:5173.
The frontend alone is enough for anything that doesn't touch the API — clubs, events, styling, the submission form's layout. The board just shows its empty state.
The USB Research Resources site's vercel dev also defaults to port 3000, so don't run both at
once: whichever starts first takes the port, and this board would quietly fill up with the other
site's documents. Run one at a time, or start this one with npx vercel dev --listen 3001 and
change the proxy target in frontend/vite.config.js to match.
Check the database connection, then create the indexes:
npm run check-db # is MONGODB_URI actually usable?
npm run ensure-indexes # once per databasecheck-db exists because Atlas answers nearly every misconfiguration with the same opaque
bad auth : authentication failed — a typo, an unencoded @ in the password, a user on the
wrong cluster, and the <db_password> placeholder never being replaced all look identical. It
reports which, and never prints the password.
ensure-indexes is not optional: signup depends on the unique index on users.email raising a
duplicate-key error, since a read-then-write check would leave a race window instead.
Every setting is read from the .env at the repo root, through backend/lib/env.js. There are
no fallback defaults anywhere: a missing variable throws a named error rather than letting a
half-configured deploy run on a guess. See .env.example for the full list — MongoDB, Groq, the
JWT signing secret, SMTP, and SITE_URL (which only the keepalive script reads).
SMTP is not optional. Verification codes and password resets go through it, so without it nobody can finish signing up — and the mailer throws rather than quietly skipping, because a silent skip produces accounts nobody can ever verify while the API still answers 200.
The frontend reads no environment variables at all. Its handful of public settings are plain
constants in frontend/src/config.js, so nothing secret can be inlined into the browser bundle
by accident. Anything that has to agree across the two halves is duplicated between that file
and backend/lib/constants.js, with a comment on each saying so.
Both boards run the same pipeline, and each step is deliberately placed:
- Method and body-size guards. The showcase image travels inline, so
/api/submitallows a much larger body than the others — but still under Vercel's 4.5 MB hard limit, so an oversized request gets a readable error rather than being dropped at the edge. - Authentication. Both boards require a verified account, and the poster's identity comes from the token, never from the request body.
- Validation (
projectInput.js/openProjectInput.js). Types, length caps, the enums, and any link's scheme — an unvalidated link is rendered as anhrefon a public page, sojavascript:URLs are rejected here. - Image decoding (
backend/lib/projectImage.js, showcase only). The declared MIME type is never trusted; the content type stored is the one sniffed from the file's own magic bytes, so this origin can't be made to serve an HTML document announced as a PNG. - Rate limiting (
backend/lib/rateLimit.js), 5/hour keyed on IP and account. Placed after validation so a typo doesn't burn a slot, and before moderation so the Groq call can't be spammed. The counters live in MongoDB with a TTL index, because per-process memory doesn't work when every lambda instance has its own. - Per-account cap, checked before moderation: somebody already at their limit cannot succeed no matter what the model says, so spending a Groq call to tell them that is waste.
- LLM moderation. The model answers with a single
1or0. It fails closed: only an explicit1approves, so a blank, garbled, or refused reply rejects. The submission is wrapped in<submission>tags and the prompt tells the model to treat everything inside as data, never instructions. - Save. An approved post is live immediately — moderation is the approval step.
Edits run the same validation and moderation as creates. Otherwise posting something clean and rewriting it afterwards would be a way to publish anything at all.
A rejection is a 400 with stage: "moderation"; the model being unreachable is a 500 with
stage: "moderation-unavailable". The two are distinct because the first is the submitter's to
fix and the second is ours.
Submitted images are downscaled in the browser (frontend/src/lib/imageUpload.js) to at most
1600px on the long edge and re-encoded as WebP, falling back to JPEG. A 9 MB phone photo becomes
a couple of hundred KB before it ever leaves the page, which is what makes an inline upload
viable inside a serverless request at all.
They are stored in MongoDB alongside the project and served by GET /api/projects/:id/image.
The board's JSON omits the bytes, so cards fetch them in parallel and the browser caches them
for a year — a project's image never changes, and the URL is keyed by document id.
MongoDB Atlas pauses a free cluster after 30 days without a connection, and a paused cluster
refuses all connections until somebody clicks Resume. The serverless API only touches Mongo when
somebody visits, so a quiet summer could genuinely reach that. .github/workflows/keep-database-awake.yml
sends one request a day through the public endpoint. Set SITE_URL as a repository variable
(Settings → Secrets and variables → Actions → Variables).
Note that GitHub disables scheduled workflows on public repos after 60 days with no new commits.
The palette, typography and motion vocabulary come from purdueusb.com and are shared with
the Research Resources site: #FFCA44 gold, #333333 charcoal, Montserrat for headings and
Raleway for body text, on a warm off-white page. Tokens are declared in an @theme block in
frontend/src/index.css — Tailwind v4 no longer auto-loads a tailwind.config.js, so tokens
defined there would emit no CSS at all.
Fonts are self-hosted via @fontsource rather than loaded from Google Fonts, because
vercel.json sets a Content-Security-Policy with font-src 'self' data: that would block a CDN
request outright.