David ran six subagents against your codebase overnight on a branch called ai-overnight-improvements (off main, not pushed). Eleven commits landed — 7 real bug fixes and 4 a11y/style tweaks. Below are the executive summary and full reports. Start with Morning Report if you only read one.
Overnight Report — `ai-overnight-improvements`
Six subagents ran in parallel for ~13 minutes against a fresh branch off main.
This file is the executive summary; the detailed reports live in
01_BUGS.md through 06_ARCHITECTURE.md.
What's on the branch
Branch: ai-overnight-improvements (off main, 11 commits, not pushed).
Working tree is clean. The AI_OVERNIGHT_REPORTS/ directory is intentionally
untracked — you can decide whether to commit it, delete it, or move it.
6c88f0ce fix: initiated-votekick log message swallowed by username argument
b67e4540 fix: guard Room.sendToSocket against missing socket
49dae858 fix: honor threePartBonuses filter in random-bonus selection
119a01f7 a11y: add autocomplete hints to account form fields
ef57a756 fix: starred-question lookup sort order was silently ignored
01b3012b fix: reveal tossup answer when all remaining players have buzzed
56451097 fix: votekick logic - allow superpowers to count, only kick on success
272f0b0a Revert accidental inclusion of WIP votekick changes
d4a6833b fix: correct operator precedence in modaq metadata field
777a329f a11y: add labels and screen-reader text to icon-only controls
e3cae38c style: fix navbar background and logo in night mode
Stat: git diff main..HEAD --stat shows 22 files / +72 / −50 lines — all
tightly scoped, no rewrites.
272f0b0a is a self-revert: the design subagent's empty-state commit
accidentally absorbed working-tree edits the bug subagent was making to the
votekick logic, and was reverted immediately. The intended votekick fix
landed cleanly later in 56451097. The ServerMultiplayerRoomMixin.js diff
in HEAD vs main matches the committed votekick fix and contains no
unintended changes — verified.
TL;DR by area
| Area | Verdict | Highest-priority item |
|---|---|---|
| Bugs | Several real ones, 7 fixed | Tossup answer was never revealed if a buzzer disconnected (the same bug that's on fix-buzz-count-after-leave) |
| Security | Needs urgent attention | Password hashing is triple-SHA256, not bcrypt; JWT/cookie secrets default to literal 'secret'/'salt' if env vars unset |
| Performance | Mostly OK; a few real wins available | Unanchored regex on /api/query does full collection scans (10–50× win with text index) |
| UX / a11y | Functional but rough edges; 4 fixes | Night-mode navbar was a giant light-blue stripe with a black wordmark; fixed |
| Features | 13 ranked proposals | Post-game Claude-API tutor + spaced-repetition queue rank highest |
| Architecture | Solid for current scale; risks at 5K+ | In-memory multiplayer state lost on every Heroku restart (and there's a daily 8am UTC one) |
1. Bugs fixed (7)
In rough order of how-bad-was-this:
-
01b3012b— Tossup answer never revealed when buzzes outnumber sockets.quizbowl/TossupRoom.js:135. This is the same class of bug that thefix-buzz-count-after-leavebranch was created to fix; the subagent re-derived it independently from scratch. Reasonable to retire that branch. -
d4a6833b—modaqmetadata field was always wrong due to ternary precedence:\${cat} - ${sub}` + alt ? ` - ${alt}` : ''. The left string is always truthy, so the field was either-or'', never- `. Pure logic bug. -
56451097— Votekick logic: ineligibility check ignoredsuperpowers, and initiating a votekick added the target tokickedUserListbefore the vote was successful, so a single initiation kicked the target on reconnect. Both fixed. -
ef57a756— Starred-question lookups were passing an aggregation pipeline stage as theoptionsarg tofind(), silently dropping the sort. Replaced with{ sort: { _id: -1 } }. -
49dae858—threePartBonusesfilter was destructured by callers and validated by the route but never read bygetRandomBonuses, so 4+ part bonuses could still leak through. -
b67e4540—Room.sendToSocketcrashed on a missing socket (synchronous TypeError that escaped the WebSocket message handler). Guarded. -
6c88f0ce—initiated-vktoast rendered the whole sentence in the username slot and "undefined" in the message slot. Mis-split args.
Bugs proposed but NOT fixed (6) — see 01_BUGS.md
Worth your judgment:
- Unbounded growth of
tossupBonusRooms+ a 5-min cleanup interval per room that never gets cleared. Slow leak, but real. - "Joined on another tab" timer kicks the new tab, not the old one — product-sensitive, the agent didn't touch it.
- Unhandled promise rejections from
adjustQuery— in Node 15+ this crashes the process by default. Probably worth adding awaits / catches. votekickListnever pruned — locks out re-initiation against the same target until restart.closeConnectionmay end the bonus prematurely when a non-eligible player leaves during a bonus.- Votekick rate-limit timestamp is set before validity checks — minor UX issue.
2. Security — needs urgent attention
Five high-severity findings (02_SECURITY.md). The two I'd act on this week:
server/authentication.js:70-76— Passwords aresha256× 3 with a single global salt. No work factor, no per-user salt, GPU-crackable in seconds if the DB ever leaks. Migrate to bcrypt (12 rounds). This is a soft migration: rehash on next successful login, keep both verifiers during transition, then drop the legacy verifier.server/authentication.js:16-17+app.js:32— IfSECRET,SALT,SECRET_KEY_1,SECRET_KEY_2env vars are unset, defaults are the literal strings'secret','salt','secretKey1','secretKey2'. Verify in Heroku that all four are set in production. Then remove the fallbacks so the server refuses to boot without them, rather than silently running with publicly-known secrets.
Also high but less urgent:
cookie-sessionis missinghttpOnly,secure, andsameSite. Easy one-liner fix inapp.js:30-34.ws8.8.0 has GHSA-58qx-3vcg-4xpx (uninitialized memory disclosure).npm update wsto 8.21+.routes/auth/send-password-reset-email.js:8enables username enumeration by returning different status codes for valid vs invalid usernames.
Medium-severity items worth looking at: JWT tokens have no exp claim,
reset/verify tokens live in process memory (and don't survive the daily
8am restart!), no CSRF protection, admin-role check is binary (no
scoping), and there's dangerouslySetInnerHTML on admin pages that takes
question data raw.
3. Performance — modest, real wins
Two are worth doing soon:
- Unanchored
$regexsearches indatabase/qbreader/get-query.js(lines 168, 172, 212, 213, 217). With ~50K+ questions, every/api/querydoes a full collection scan. Adding a MongoDB text index onquestion_sanitized,answer_sanitized, etc., is a 10–50× latency win (subagent's estimate; sounds about right). routes/ssi-middleware.jsdoes a synchronousfs.readFileper HTML request and never caches the rendered result. Cache the substituted HTML once at startup. Trivial; helps Heroku cold-start.
Speculative but plausible:
- Multiplayer rooms broadcast full state on every event. Subagent estimates 50–70% WS-traffic reduction with deltas; I'd want to measure before committing to this — it's a protocol change.
- Per-room
setIntervalfor ban/kick cleanup never stops, even on empty rooms. Trivial fix once you also fix the room-cleanup leak above. - No
Cache-Controlon static assets. One-line change toexpress.static(...). - Leaderboard aggregation has no cache and rescans on every request.
Full report in 03_PERFORMANCE.md includes bundle sizes, query patterns,
and 14 ranked findings.
4. UX / a11y — 4 fixes landed
Committed:
- Night-mode navbar fix (
e3cae38c) — the navbar background was hardcoded#dfebfboutside the theme, producing a light-blue bar on a dark page with a black wordmark. Now uses a dark slate bg and a light logo suffix. - Icon-only buttons got
aria-labels (777a329f) — pause, settings gear, theme dropdown, plus fixing two "nagivation" typos and converting the "I was wrong"<a>(nohref, not focusable) to a<button>. - Empty state for the multiplayer public room list — was a blank gap when all rooms have zero online players; now shows a muted row pointing to the "show empty rooms" toggle.
autocompletehints on every account form (119a01f7) — password managers and mobile autofill were guessing (and getting it wrong).
Proposed (not done):
.checkbox-menudropdown still hardcodes white in night mode (~20 min)funny-toastred-on-yellow is borderline WCAG AA (~10 min)- 404 page has no navigation back home — just a quizbowl question (~10 min)
- 20+
window.alertcalls in admin should become toasts (a few hours) <a>used as a button throughoutclient/db/index.jsx- Pagination has no
aria-current="page"
Full report in 04_DESIGN.md.
5. Feature proposals — top picks
From 05_FEATURES.md, ranked:
- Personalized question recommendations [M] — surface tossups from the user's weakest categories.
- Post-game Claude tutor [M] — Sonnet, with prompt caching on question metadata. Cost estimate ~$0.02–0.05 per round. Explains clue chains, suggests drills. This is the highest-leverage AI feature for a quizbowl site; nothing else is close.
- Spaced-repetition queue [M] — Anki-style review of missed clues and answers.
- Time-series stats by category [M] — trending power/neg/conversion rates with weekly granularity.
- Embeddable QOTD widget [S] — for school/club sites and Discord. Cheap viral surface.
Second tier: tournament brackets, TTS reading, public leaderboards, bot opponents, spectator mode.
Long-term: question-authoring UI, Discord OAuth, better errata workflow.
Explicitly not recommended by the subagent: native mobile app (PWA is better ROI), AI question synthesis (community-written questions are better), classroom mode (spectator mode covers it).
6. Architecture — solid for now, predictable cliffs
From 06_ARCHITECTURE.md. The site is well-designed for ~100–500
concurrent players. Risks past that:
- In-memory multiplayer state — every dyno restart loses all rooms. Heroku restarts at 8am UTC daily. Add Redis-backed room state is the headline refactor.
- No WebSocket reconnect — a transient blip drops the player. Worth a sticky session-id + reconnect handshake.
- One god file at 477 lines (
ServerMultiplayerRoomMixin.js) doing routing + game logic + admin + votekick. Split into focused classes. - Zero tests. Highest-ROI place to start: an integration test of the room lifecycle (join → buzz → answer → next).
- No uncaught-error handler on the WS message loop — one throw kills
the room (related to the
adjustQuerypromise-rejection finding above). - Single dyno — no redundancy. Probably fine while costs/operations matter more than uptime, but worth being intentional about.
The architecture review's top-5 refactors-by-leverage list is the right 6-month roadmap if you want one.
What I'd do first
If your friend wants a punch-list for the morning:
Today (security):
1. Confirm SECRET, SALT, SECRET_KEY_1, SECRET_KEY_2 are set in
Heroku config vars. Strip defaults from server/authentication.js:16-17
and app.js:32.
2. npm update ws → 8.21+.
3. Add httpOnly: true, secure: true, sameSite: 'strict' to
cookieSession in app.js.
This week:
4. Plan the bcrypt migration. Soft migration with dual-verifier is the
sane path.
5. Review and merge the 7 bug-fix commits on ai-overnight-improvements
(or cherry-pick the ones you trust).
6. Decide on the proposed-bugs in 01_BUGS.md — most are real but
product-sensitive.
This month:
7. Add a Mongo text index for question_sanitized / answer_sanitized.
This is a single command and gives the biggest user-visible perf win.
8. Move reset/verify tokens to a Mongo collection with a TTL index — the
daily 8am restart currently invalidates every in-flight email link.
9. Write the first multiplayer-room integration test.
Next quarter: 10. Redis-backed multiplayer state + WebSocket reconnect. 11. Pick one AI feature to ship (post-game tutor is the obvious one).
Trust-but-verify notes
I spot-checked the agents' work but not exhaustively:
- The committed code diff is tightly scoped and all 22 changed files are
consistent with the reports. No surprises in the ServerMultiplayerRoomMixin
diff against main after the revert.
- I verified the most alarming security claim (triple-SHA256 hashing) by
reading server/authentication.js directly. Confirmed.
- The bug-fix commits passed npm run lint per the agent (semistandard
--fix). I did not run the test suite — there isn't one.
- Performance estimates ("10-50× win", "50-70% WS traffic reduction") are
the subagent's gut estimates from code-reading, not measurements.
Treat them as "this is worth measuring" not "this is the number."
- Bundle / file sizes in the perf report were measured.
Sleep well — the heavy lifting waiting for you is the bcrypt migration and confirming production secrets. Everything else can move at your pace.
Bug Audit
Fixed (committed)
-
modaq metadata is always wrong —
database/qbreader/get-packet.js:15,32— The expression\${cat} - ${sub}` + alt ? ` - ${alt}` : ''parses as(string + alt) ? ` - ${alt}` : ''. Because the left side is always truthy,metadatawas either-(when alt was set) or''(when unset), never the intended- line. Parenthesized the ternary so the suffix is properly conditional. Commitd4a6833b`. -
Votekick eligibility ignores superpowers; initiated votekick kicks target immediately —
server/multiplayer/ServerMultiplayerRoomMixin.js:402,427-433,437— (a) Eligibility checktens === 0 && powers === 0left outsuperpowers, so a player whose only correct buzzes were superpowers (20-point) could not initiate or vote a votekick. (b) InvotekickInit, the "initiated" branch (vote not yet successful) was callingkickedUserList.set(targetId, …), which is the same mapconnection()uses to enforce removal. So a single initiation kicked the target on reconnect before any threshold was reached. Added superpowers to both checks, and moved thekickedUserList.setplus asetTimeout(closeConnection, 1000)into the success branch so they only fire whenvotekick.check()is true (mirroringvotekickVote). Commit56451097. -
Tossup answer never revealed when buzzes outnumber sockets —
quizbowl/TossupRoom.js:135— Condition wasbuzzes.length === Object.keys(sockets).length, butthis.buzzesis never pruned when a player leaves. If a player negged then disconnected, every subsequent neg pushedbuzzes.lengthabovesockets.length(sockets gets pruned on disconnect, buzzes doesn't), so the answer was never revealed until the question ran out of words. Changed toObject.keys(sockets).every(id => buzzes.includes(id)), which ignores stale userIds and treats newly joined players who haven't buzzed as still able to buzz. Commit01b3012b. -
Starred-question sort was silently ignored —
database/account-info/stars/get-tossup-stars.js:11-15,database/account-info/stars/get-bonus-stars.js:11-15—find()only accepts(filter, options). The callers passed three positional args - filter, a{$addFields: ...}pipeline stage, and a{$sort: ...}pipeline stage. The driver treated the pipeline stage as the options object (with no recognized keys) and dropped the third arg, so starred questions returned in whatever order the index handed them back. Replaced with{ sort: { _id: -1 } }, which gives newest-first by ObjectId timestamp. Commitef57a756. -
threePartBonusesfilter never applied to random bonuses —database/qbreader/get-random-bonuses.js:23-33—BonusRoomdefaultsquery.threePartBonuses = trueand the/api/random-bonusroute validates the field, butgetRandomBonusesonly destructuredbonusLengthand never readthreePartBonuses, so 4+ part bonuses could still be sampled. AddedthreePartBonusesto the destructure and translated it intobonusLength = 3whenbonusLengthis unspecified, leaving any directbonusLengthcallers unaffected. Commit49dae858. -
Room.sendToSocketcrashes when socket is gone —quizbowl/Room.js:59-62— The method dereferencedthis.sockets[userId].sendwithout checking whether the socket existed. Call sites like the "joined on another tab" path, the toggle-mute echo, and the various ban/kick error notifications can race against a disconnect. The resulting TypeError was synchronous and would escape the WebSocket message handler. Guarded with an early return when the socket is missing. Commitb67e4540. -
initiated-vktoast renders the message in the wrong slot —client/play/mp/MultiplayerTossupBonusClient.js:763-765—logEventConditionally(username, message)returns early whenusername === undefined, and otherwise writes the two args into distinct DOM elements. The handler was called with the full sentence as the first arg and no second arg, so the sentence rendered in the "username" span and the message span said "undefined". Split the text sotargetUsernamegoes in the username slot and the rest of the sentence in the message slot, matching every other call site. Commit6c88f0ce.
Proposed (needs review)
tossupBonusRoomsand per-room intervals never get cleaned up [severity: med] —server/multiplayer/handle-wss-connection.js:21,42-55,server/multiplayer/ServerMultiplayerRoomMixin.js:50—- Bug: Every user-created room name becomes a permanent entry in
tossupBonusRoomsand starts asetInterval(every 5 minutes, forever) that runscleanupExpiredBansAndKicks. Over time both the map and the registered timers grow without bound. The/api/multiplayer/room-listendpoint also iterates this growing map on every request. - Repro: Create N rooms with unique names, leave them all empty.
Observe that
tossupBonusRoomssize and active timer count never decrease. -
Proposed fix: In
closeConnection(orleave), if the room has no more online players and is not inPERMANENT_ROOMS/VERIFIED_ROOMS,clearIntervalon the cleanup timer (store the handle on the room) anddelete tossupBonusRooms[roomName]. Needs care to not delete mid-game, and to ensure no other reference holds onto the room. -
Stale "joined on another tab" timer kicks the new tab [severity: med] —
server/multiplayer/ServerMultiplayerRoomMixin.js:94-97— - Bug: When a user joins from a second tab, the code schedules
closeConnection({ userId, username })5 seconds later. By the time the timeout fires,this.sockets[userId]has already been replaced by the NEW tab's socket, socloseConnectionremoves the second tab the user actually wants. Closing the original socket directly (instead of viacloseConnection) does not help either, because itscloselistener was bound to callcloseConnection(userId, ...)and will fire on close. - Repro: Join a multiplayer room. Open the same URL in a second tab. Within 5 seconds, the second tab gets booted.
-
Proposed fix: Stash and remove the close listener on the old socket before swapping in the new one (or compare socket identity inside
closeConnectionto ensure it's the same socket that owns the entry). Behavior here is product-sensitive (current code intends to disconnect the OLD tab) so I left it alone. -
adjustQueryrejections are unhandled [severity: med] —quizbowl/QuestionRoom.js:101plus every call site (TossupRoom, BonusRoom, ServerMultiplayerRoomMixin, multiple methods in QuestionRoom) — - Bug:
adjustQueryis async and awaits DB calls (getPacket,getRandomQuestions). Every call site invokes it withoutawaitor.catch. Any thrown error becomes an unhandled promise rejection - in Node.js 15+ that crashes the process by default. - Repro: Force
getPacketorgetRandomTossupsto throw (e.g. transient mongo error) while a user changes set name or difficulty. Process exits withUnhandledPromiseRejection. -
Proposed fix: Either await in callers (requires marking them async; many are message handlers so this should be safe) or catch inside
adjustQueryand emit an error message back to the room. -
Rate-limit timestamp set before duplicate-vk check [severity: low] —
server/multiplayer/ServerMultiplayerRoomMixin.js:411— - Bug:
lastVotekickTime[userId] = currentTimeruns before the "is there already a votekick against this target" loop. If a user initiates against a target who already has an active vk, their 90s rate-limit clock starts even though nothing happened. -
Proposed fix: Move the timestamp assignment after the duplicate check (and the eligibility check). Minor UX cost; not fixed in this pass.
-
votekickListnever pruned [severity: low] —server/multiplayer/ServerMultiplayerRoomMixin.js:36,413-414,425-426 - Bug: Completed and abandoned votekicks stay in
this.votekickListforever, blocking re-initiation against the same target (line 413-414 short-circuits on any existing entry). Also a slow leak for long-lived rooms. -
Proposed fix: Remove the votekick entry from
votekickListwhencheck()succeeds, and add a TTL/expiry for stale ones (the same 30-minute window used for kicks/bans would be reasonable). -
closeConnectioncallsgiveAnswereven when the leaving user is not the buzzer [severity: low] —server/multiplayer/ServerMultiplayerRoomMixin.js:241-257— - Bug: The fallback path for a leaving user during a TOSSUP only
fires
giveAnswerifthis.buzzedIn === userId. That's correct. But the BONUS branch unconditionally callsendCurrentBonus({ userId, username })for any leaving user, even ones who are not on the bonus-eligible team.endCurrentBonusthen computesteamId = this.bonusEligibleTeamId ?? this.players[userId].teamIdand updates that team's stats. This is fine if a non-eligible player leaves (it ends the bonus, awarding to the eligible team), but it does end the bonus prematurely for the eligible team. - Proposed fix: Only end the bonus if the leaving player is the bonus-answering player (or all of the eligible team has left). Genuinely product-sensitive.
Not bugs but suspicious
-
allowed()truth table —ServerMultiplayerRoomMixin.js:71:(userId === ownerId) || settings.public || !settings.controlled. In a private, uncontrolled room this returnstruefor everybody, which is presumably intentional (private but cooperative) but reads weirdly. Worth a comment. -
toggleMuteechoes only to the owner —ServerMultiplayerRoomMixin.js:360-363: The server routes the mute event back to the requester only, not to the broader room. That's apparently a per-owner client-side mute; the trip through the server is wasted but harmless. Consider doing it client-side and removing the server message. -
closeConnectionin TOSSUP_PROGRESS_ENUM.READING withliveAnswerempty — server firesgiveTossupAnswerwithgivenAnswer: this.liveAnswerwhen the buzzed-in player disconnects. IfliveAnsweris still '' (they buzzed but typed nothing), this short-circuits atObject.keys(this.tossup || {}).length === 0check only if tossup is empty. Otherwise it scores '' as wrong (almost certainly intended). Worth verifying once if this neg counts the right way for someone who left. -
pausewhile the timer has already fired —TossupRoom.js:181-186pauses mid-dead-time and resumes with the cachedtimer.timeRemaining. IfrevealTossupAnsweralready ran (race), this could start a timer that callsrevealTossupAnsweragain. Hard to actually trigger because pause is disabled onceANSWER_REVEALED.
Security Audit
TL;DR
QBReader has a solid foundation with parameterized database queries, proper password hashing, and authentication checks on protected endpoints. However, there are critical issues with weak default credentials, missing cookie security flags, and an outdated WebSocket dependency with a known vulnerability. The XSS surface is limited but exists in specific admin pages.
Findings
High severity
1. Hard-coded weak default secrets (JWT, cookie signing, password hashing)
- Files:
server/authentication.js:16-17,app.js:32 - What: If environment variables are not set, the app falls back to hardcoded defaults:
- JWT secret:
'secret'(line 17) - Password salt:
'salt'(line 16) - Cookie signing keys:
['secretKey1', 'secretKey2'](app.js line 32) - Why it matters: If deployed without setting
SECRET,SALT,SECRET_KEY_1,SECRET_KEY_2env vars, an attacker can forge JWTs, crack password hashes, and sign malicious session cookies. This completely breaks authentication. - Fix: (1) Remove fallback defaults and require env vars to be set; (2) generate random defaults at startup if env vars missing; (3) validate in
app.jsstartup that all required secrets are configured.
2. Session cookies missing httpOnly and secure flags
- File:
app.js:30-34 - What: The
cookie-sessionmiddleware is configured withouthttpOnly: trueorsecure: trueflags on session cookies. This means: - Client-side JavaScript can read session cookies (httpOnly missing)
- Cookies are sent over unencrypted HTTP (secure missing)
- Why it matters: XSS on the client can steal session tokens. HTTP transmission allows MITM to intercept and reuse sessions.
- Fix: Update
cookieSession()config to include:javascript app.use(cookieSession({ name: 'session', keys: [...], maxAge: COOKIE_MAX_AGE, httpOnly: true, // prevent JS access secure: true, // HTTPS only sameSite: 'Strict' // CSRF protection }));
3. Weak password hashing (triple SHA256 without bcrypt)
- File:
server/authentication.js:70-76 - What: Passwords are hashed using plain SHA256 applied three times, with only a prepended/appended salt. This is cryptographically weak:
- SHA256 is fast, enabling brute-force attacks (no computational slowdown)
- No work factor or rounds parameter
- Single salt per deployment; all users share the same salt
- Why it matters: If the database is compromised, attackers can crack passwords in seconds using commodity GPUs.
- Fix: Replace with bcrypt:
javascript import bcrypt from 'bcrypt'; export async function saltAndHashPassword(password) { return await bcrypt.hash(password, 12); // 12 rounds } export async function checkPassword(username, password) { const hash = await getUserField(username, 'password'); return await bcrypt.compare(password, hash); }Note:checkPasswordis synchronous in current code; migrate to async.
4. Outdated ws package with known vulnerability (GHSA-58qx-3vcg-4xpx)
- File:
package.json:30, npm audit output - What:
ws8.8.0 is installed; versions 8.0.0–8.20.0 have a moderate-severity uninitialized memory disclosure vulnerability. - Why it matters: A malicious WebSocket client can craft messages that trigger memory reads, leaking sensitive data (session tokens, usernames, question content).
- Fix: Update to
ws8.21.0 or later:bash npm update ws
5. User enumeration via password reset endpoint
- File:
routes/auth/send-password-reset-email.js:8 - What: The endpoint accepts GET requests with a username parameter and returns a redirect(200) on success or redirect(500) on failure. An attacker can enumerate valid usernames by checking HTTP status codes.
- Why it matters: Attackers can build a list of valid accounts, then target them with password spray or social engineering.
- Fix: Always respond with the same status code (e.g., 200) and message, regardless of whether the username exists:
javascript router.get('/', async (req, res) => { await sendResetPasswordEmail(req.query.username); // silent fail res.redirect(200, '/'); // same response every time });
Medium severity
6. JWT tokens lack expiration (no exp claim)
- File:
server/authentication.js:61-63 - What:
generateToken()creates JWTs with onlyusernameandverifiedEmailclaims, noexp(expiration) claim. Theverify()function doesn't check expiration. - Why it matters: Stolen tokens are valid indefinitely. An attacker who compromises a single token can impersonate a user forever.
- Fix: Add expiration and validate on verify:
javascript export function generateToken(username, verifiedEmail = false) { return sign( { username, verifiedEmail, exp: Math.floor(Date.now() / 1000) + 3600 }, // 1 hour secret ); } export function checkToken(username, token, checkEmailVerification = false) { return verify(token, secret, (err, decoded) => { if (err) return false; // includes exp check return (decoded.username === username) && (!checkEmailVerification || decoded.verifiedEmail); }); }
7. Password reset tokens stored in-memory without persistence
- File:
server/authentication.js:25-26, 101, 128 - What: Reset and email verification tokens are stored in a plain JavaScript object (
activeResetPasswordTokens,activeVerifyEmailTokens) in process memory. If the server restarts, all tokens are lost. Additionally, no rate limiting on token generation per user. - Why it matters: (1) Users can't recover accounts after a server restart; (2) an attacker can spam token requests without limit, potentially filling memory or DoS-ing token generation.
- Fix:
- Store tokens in MongoDB with a TTL index (auto-delete after 15 minutes)
- Add rate limiting: max 5 reset/verify emails per user per hour
8. No CSRF protection on state-changing endpoints
- Files: All POST routes in
routes/auth/,routes/api/admin/, etc. - What: No CSRF tokens are validated. Any website can forge a cross-site request to change passwords, update profiles, or perform admin actions.
- Why it matters: A logged-in user visiting an attacker's website could have their password changed or profile modified without consent.
- Fix: Implement CSRF tokens:
- Generate a unique token per session
- Require token in POST/PUT/DELETE requests
- Validate on server before processing
- Use a library like
csurfor manual implementation
9. Admin endpoint authorization only checks user role, not ownership
- File:
routes/api/admin/index.js:17-31 - What: All
/api/admin/*routes check if the user is an admin, but not which admin is requesting. Combined with parameterized requests, this allows any admin to modify any other admin's data or perform actions across all users without additional consent. - Why it matters: A low-privilege admin (e.g., moderator) cannot be restricted to a subset of users/packets. Any admin has full write access to everything.
- Fix: Implement fine-grained role-based access control (RBAC) with resource scoping:
- Define admin roles:
superadmin,moderator,content_manager, etc. - Verify ownership/scope before processing each admin request
- Log all admin actions with timestamps and user IDs
10. Unvalidated user input in client-side admin pages (HTML injection)
- File:
client/admin/geoword/compare.js:129 - What: Line 129 renders
myBuzz.answerwithout escaping into HTML: ```javascript
Answer: ${removeParentheses(myBuzz.answer)}
The `removeParentheses()` function only strips trailing parentheses; it doesn't HTML-escape. If `myBuzz.answer` contains `<script>` or `<img onerror=...>`, it will execute.
- **Why it matters:** An admin viewing results could be XSS'd if a user crafted a malicious answer string (e.g., during buzzing). Admin token would be stolen.
- **Fix:** Escape all user data before inserting into HTML:javascript
```
11. Stored XSS in admin category reports via dangerouslySetInnerHTML
- File:
client/admin/category-reports/index.jsx:26, 28, 65, 69, 71 - What: Raw question/answer/part data from the database is rendered via
dangerouslySetInnerHTML. If an admin or user ever inputs HTML into question fields during bulk uploads or admin edits, it will execute. - Why it matters: Although questions are curated, if a bulk import tool or admin API allows HTML in question text, this becomes a stored XSS vector affecting all admins.
- Fix: Use React's auto-escaping or sanitize before rendering:
javascript <span dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(tossup.question) }} />
Low severity / hygiene
12. Rate limiting is lenient and applies equally to all endpoints
- Files:
routes/auth/index.js:21-26,routes/api/index.js:30-35 - What: Auth endpoints are rate-limited to 20 requests per 1000ms (20 req/sec) per IP. This is very lenient for password attacks.
- Why it matters: An attacker can attempt 1200 login guesses per minute per IP. With multiple IPs, account lockout is ineffective.
- Fix:
- Tighten auth endpoints: 5 req/min per user (not IP)
- Implement account lockout: 3 failed attempts → 15-min lockout
- Use username-based rate limiting for login/reset, not just IP
13. No security headers (CSP, X-Frame-Options, X-Content-Type-Options)
- Files:
app.js - What: No
helmetmiddleware or manual headers configured. - Why it matters: Missing CSP allows inline scripts and external script injection. No frame-busting allows clickjacking.
- Fix: Add helmet:
bash npm install helmetjavascript import helmet from 'helmet'; app.use(helmet({ contentSecurityPolicy: { directives: { defaultSrc: ["'self'"], scriptSrc: ["'self'", "'nonce-...'"], // if inline scripts needed styleSrc: ["'self'", "'unsafe-inline'"], imgSrc: ["'self'", 'data:', 'https:'] } } }));
14. CORS enabled without origin whitelist
- File:
routes/index.js:23 - What:
cors()is applied to all/api/*routes with default settings, allowing any origin to make cross-origin requests to the API. - Why it matters: A malicious website can use CORS to call API endpoints on behalf of a logged-in user (if credentials are included).
- Fix: Whitelist origins:
javascript router.use('/api', cors({ origin: process.env.ALLOWED_ORIGINS?.split(',') || ['https://www.qbreader.org'], credentials: true }), apiRouter);
15. Incomplete validation of email and username input
- File:
server/authentication.js:141-156 - What:
validateUsername()checks length and banned list, but doesn't validate format or prevent Unicode tricks (e.g., lookalike characters). Email is never validated at signup. - Why it matters: Usernames could contain zero-width characters or homoglyph attacks. Invalid emails cause silent failures in password reset.
- Fix:
javascript export function validateEmail(email) { const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; return re.test(email); } export function validateUsername(username) { return /^[a-zA-Z0-9_-]{1,20}$/.test(username) && !banList.includes(username.toLowerCase()); }
16. No database connection error handling for authentication
- File:
server/authentication.js - What: Functions like
getUserField()andgetUserId()can fail silently or throw, but callers don't always handle errors. E.g.,sendResetPasswordEmail()returns false on error, but doesn't log it. - Why it matters: Silent failures could allow account lockout or confused state if the database is down.
- Fix: Add explicit error handling and logging:
javascript export async function sendResetPasswordEmail(username) { try { const email = await getUserField(username, 'email'); if (!email) return { success: false, reason: 'no_email' }; // ... rest } catch (err) { console.error('Reset password error:', err); return { success: false, reason: 'server_error' }; } }
17. WebSocket messages lack input validation and can cause XSS
- File:
server/multiplayer/handle-wss-connection.js:63-162 - What: Username is read from query params and used directly in messages. Room names are sanitized, but messages from the room (chat, answers) are not validated before broadcast.
- Why it matters: While the client sanitizes room names, a crafted WebSocket message could broadcast unsanitized HTML/JS to other players.
- Fix: Validate and sanitize all WebSocket message payloads server-side before broadcast.
18. No logging of security-relevant events
- Files: Throughout
routes/auth/ - What: Login attempts, password resets, profile changes, and admin actions are commented out or missing.
- Why it matters: No audit trail to detect brute-force attacks, unauthorized access, or account takeovers.
- Fix: Implement structured logging:
javascript function logSecurityEvent(event, user, details) { console.log(JSON.stringify({ timestamp: new Date().toISOString(), event, username: user, details, ip: req.ip })); // Also write to database or external log service }
19. No rate limiting on POST /api/report-question
- File:
routes/api/report-question.js - What: The endpoint accepts POST requests with no auth check and no per-user rate limit. An attacker can spam false reports.
- Why it matters: Database can be bloated with junk reports; legitimate reports get lost in noise.
- Fix: Add IP-based rate limiting (10 reports per day per IP).
20. Regex DoS possible in database queries
- File:
database/qbreader/get-query.js:273 - What: The
setNameparameter is mapped to regex patterns. If a user provides input like(a+)*b, it can cause catastrophic backtracking. - Why it matters: A single malicious query request can hang the database, causing DoS.
- Fix: Escape or compile regexes with timeout / complexity checks:
javascript const maxRegexLength = 1000; if (name.length > maxRegexLength) { throw new Error('Pattern too long'); } // Or use a timeout library
What looks good
✅ Parameterized database queries: All MongoDB queries use proper operators ($in, $regex with options) instead of string concatenation. No NoSQL injection vectors found.
✅ Password storage with salt: Passwords are salted (though not with bcrypt).
✅ Authorization checks on protected endpoints: Admin and user-specific routes properly verify checkToken() before allowing access.
✅ Email verification flow: Email links expire in 15 minutes and are one-time use (token cleared after use).
✅ Stripe webhook signature verification: stripe.webhooks.constructEvent() properly validates HMAC signatures.
✅ Object ID validation: Admin and user-facing endpoints validate ObjectId format before querying (new ObjectId() with try/catch).
✅ Response status codes: Consistent use of proper HTTP status codes (401 for auth failure, 403 for forbidden, 400 for bad input).
✅ User enumeration partially mitigated on login: Login endpoint returns 401 for both bad username and bad password (doesn't reveal which).
✅ HTML escaping in some client code: The escapeHTML() utility is used correctly in several admin pages (compare.js, category-stats.js).
✅ DOMPurify installed: Package is installed (though not used client-side currently).
Out of scope / not checked
- Infrastructure security (Heroku dyno security, database access controls, monitoring)
- SSL/TLS certificate validation (assumed to be handled by Heroku)
- Third-party API security (nodemailer, Stripe SDK internals)
- Client-side package vulnerabilities (beyond ws) — full
npm auditreport not reviewed - Social engineering / phishing on password reset emails
- Backup and disaster recovery security
- Rate limiting under sustained attack (may not scale with connection pooling)
- Heroku environment variable exposure (assume proper secrets management)
Performance & Efficiency Audit
TL;DR
The qbreader site has several preventable performance bottlenecks. The biggest wins are: (1) full-collection scans via unanchored regex in search queries—these should use wildcard indexes or be rewritten to avoid regex; (2) synchronous file I/O on every page load in ssi-middleware.js; (3) per-connection timers in multiplayer rooms that keep running even when idle; (4) no HTTP cache headers on cacheable assets; (5) broadcast of huge state objects on every event instead of deltas. The frontend is reasonably optimized (webpack code-split, production mode), but there's DOM thrashing on multiplayer chat.
High-impact opportunities (worth doing)
1. Unanchored regex searches cause full collection scans
- Location:
/home/david/code/website/database/qbreader/get-query.js:168, 172, 212, 213, 217 - Current behavior: Query routes use
$regex: wordwithout anchors for substring matching. On/api/querywith large datasets, this triggers a collection scan instead of using an index. Thequestion_sanitized,answer_sanitized,leadin_sanitized,parts_sanitizedfields are all regex-searched but almost certainly have no text index. - Estimated cost: Each query request that hits these fields scans the entire collection. On a busy site, dozens of queries/min × collection size (assume 50K+ documents) = millions of BSON bytes scanned per minute.
- Proposed change: Create a MongoDB text index on searchable fields (or use wildcard index for fields like
{$regex: '.*' + word + '.*'}). Alternatively, redesign search to use a dedicated search engine (Elasticsearch, Meilisearch) if regex is critical. For now, at minimum add{ question_sanitized: 'text', answer_sanitized: 'text', ... }index to tossups and bonuses collections. - Estimated win: 10-50× faster search latency depending on collection size; dramatically less CPU/IO on Heroku dyno.
2. Synchronous readFileSync blocks the event loop on every non-API request
- Location:
/home/david/code/website/routes/ssi-middleware.js:4-5 - Current behavior:
fs.readFileSync('./client/ssi/head.html', 'utf8')andfs.readFileSync('./client/ssi/nav.html', 'utf8')run once at module load, but then for every HTML request, the middleware callsfs.readFile()(async) to read the page, then string-replaces the SSI includes. The initial sync reads are a startup cost (~1-2ms), but more importantly, there's no caching of rendered HTML. - Estimated cost: Cold starts on Heroku take ~60s. Even 2-3ms per HTML request × 50+ requests during boot eats into the timeout. In production, if a dyno restarts and users hammer the site, every page load re-reads and re-processes HTML.
- Proposed change: (1) Cache the rendered HTML (with SSI includes already substituted) using a Node cache or Redis. (2) Invalidate on deploy or use a simple in-memory LRU cache. (3) Pre-compile at startup instead of on-demand.
- Estimated win: 1-2ms faster per HTML request; faster cold starts; reduced disk I/O.
3. Per-room timer intervals keep running idle rooms alive
- Location:
/home/david/code/website/quizbowl/Room.js:77-88(startServerTimer), and in/home/david/code/website/quizbowl/TossupRoom.js:193-235(readTossup with setTimeout chain) - Current behavior: Each room has a
this.timer.intervalmanaged bystartServerTimer(). In multiplayer rooms, this is cleared and restarted for every tossup/bonus cycle. ThereadTossup()function chains recursivesetTimeout()calls to deliver words at calculated intervals. A room with 0 players online still has these intervals running if a question is in progress. - Estimated cost: On Heroku, each running interval consumes CPU time. If 100+ idle rooms exist (permanent rooms, abandoned rooms), they're each burning ~1% CPU per active interval, even with no players.
- Proposed change: (1) Stop timers when the room has no online players (add check in
emitMessage()to bail early ifObject.values(this.sockets).length === 0). (2) For permanent rooms or rooms with lingering timers, add a heartbeat that clears stale timers after 30min of no activity. - Estimated win: 5-20% CPU reduction on idle rooms; lower dyno memory footprint.
4. Leaderboard aggregation scans entire per-tossup-data and per-bonus-data collections
- Location:
/home/david/code/website/database/account-info/leaderboard.js:24-48 - Current behavior: The leaderboard endpoint runs
$unwindon the entiredataarray, then$groupby user, then$lookupto join user names. With potentially millions of tossup/bonus records, this is expensive. No pagination or caching. - Estimated cost: A leaderboard query might take 1-5 seconds; if users refresh it frequently, cumulative impact is significant on Heroku dyno memory.
- Proposed change: (1) Cache the result for 5-10min (Redis or in-memory). (2) Add a compound index on
data.user_id(or denormalize user stats). (3) If the stats collections are too large, consider archiving old data or using a materialized view (pre-computed leaderboard document). - Estimated win: Instant responses after first query; reduced aggregate processing.
5. Full room state broadcast on every message, no delta updates
- Location:
/home/david/code/website/server/multiplayer/ServerMultiplayerRoomMixin.js:140-162(connection), and throughout the codebase viaemitMessage()in/home/david/code/website/quizbowl/Room.js:32-36 - Current behavior: When a player joins, the server sends 4 large JSON messages with full
players,teams,currentQuestion, etc. Every tossup/bonus event broadcasts the full state. For 10+ players, each message is 5-10 KB; multiplied by every event, this is network churn. - Estimated cost: On a 10-player room with 50 events/min, that's ~250-500 KB/min of redundant data. Mobile users or slow connections see lag; dyno I/O is higher.
- Proposed change: Send only the changed fields on updates (delta patches). On initial connection, send full state; on updates, send
{ type: 'player-update', userId, field, value }or a JSON patch. - Estimated win: 50-70% reduction in WebSocket traffic; faster updates for clients; lower dyno network egress.
6. No HTTP Cache-Control headers on static assets or cacheable endpoints
- Location:
/home/david/code/website/routes/index.js:29-32(static file serving) - Current behavior:
express.static()serves client files without explicit Cache-Control headers. Clients always revalidate or re-download. CSS, JS bundles, and HTML are not cached. - Estimated cost: Every page load re-fetches all assets, even if unchanged. On Heroku (metered bandwidth), this costs real money and slower perceived load times.
- Proposed change: Add
app.use(express.static(..., { maxAge: '1d', etag: false }))for versioned assets. For HTML, useno-cache, must-revalidate, public. For the API, add appropriate cache headers based on data freshness (e.g., set-list could be cached for 1h). - Estimated win: 80-90% reduction in asset download on repeat visits; faster LCP/FCP; lower egress costs.
7. Frequency list aggregation doesn't limit output size early
- Location:
/home/david/code/website/database/qbreader/get-frequency-list.js:33-102 - Current behavior: The aggregation pipeline unwraps
answers_sanitized, processes all of them, sorts in-memory, then slices at the end. For a large subcategory, this might process millions of documents before truncating to the limit. - Estimated cost: Slow queries when limit is small (e.g., limit=50 but scans 1M documents); high memory usage in aggregation.
- Proposed change: Add
$limitearlier in the pipeline, or add$sampleif appropriate. At minimum, move the$limitbefore the final sort. - Estimated win: 2-5× faster queries on large datasets; lower memory usage during aggregation.
Medium-impact
8. Regex patterns in set.name search could be optimized
- Location:
/home/david/code/website/database/qbreader/get-query.js:271-276 - Current behavior:
set.nameis searched with$in: [new RegExp(...)], which still requires scanning (albeit with an index onset.nameif it exists). For typical use, an exact match or prefix search would be faster. - Estimated cost: Each set-name query doesn't do a full collection scan (if
set.nameis indexed), but regex is slower than exact match. - Proposed change: If set names are stable and known upfront, use exact
$in: [...]instead of regex. Otherwise, ensure a text or substring index exists onset.name. - Estimated win: 10-20% faster set filtering per query.
9. DOM churn in multiplayer chat updates
- Location:
/home/david/code/website/client/play/mp/MultiplayerTossupBonusClient.js:50-80(chat message handling) - Current behavior: Every chat message creates new elements via
document.createElement()and DOM manipulation. Live chat updates (real-time typing) re-find elements by ID, which triggers DOM reflows. - Estimated cost: On a chatty room, dozens of DOM operations per second can cause jank and high CPU on client.
- Proposed change: Use a document fragment or a virtual-DOM-like approach (React would help here). Pre-allocate chat message containers.
- Estimated win: Smoother UI, lower client CPU.
10. Rate limiter uses Set per socket, could use token bucket or sliding window
- Location:
/home/david/code/website/server/multiplayer/ServerMultiplayerRoomMixin.js:39-40, 123-126 - Current behavior:
this.rateLimitExceededis a Set that tracks usernames. Once added, a user is rate-limited forever (until next restart). This is too aggressive. - Estimated cost: Users hitting rate limits may think the site is broken; legitimate bursts (e.g., rapid question navigation) trigger false positives.
- Proposed change: Use a proper rate limiter (e.g., the
express-rate-limitpackage already imported, or implement token-bucket). Reset per-minute or per-request window. - Estimated win: Better UX; fewer false positives.
Nits / micro-optimizations
11. Object.keys(this.players) called multiple times per votekick
- Location:
/home/david/code/website/server/multiplayer/ServerMultiplayerRoomMixin.js:266, 417 - Current behavior:
for (const userId of Object.keys(this.players))and then laterObject.keys(this.sockets).includes(userId)creates new arrays each time. - Proposed change: Cache
Object.keys()or usefor (const userId in this.players). - Estimated win: <1% CPU, not worth refactoring alone.
12. Votekick threshold calculation iterates over all players
- Location:
/home/david/code/website/server/multiplayer/ServerMultiplayerRoomMixin.js:416-420 - Current behavior:
Object.keys(this.players).forEach(...)counts online players every time. In a large room, this runs a lot. - Proposed change: Track online count as a running counter, increment/decrement on join/leave.
- Estimated win: <1% CPU reduction per votekick.
13. Banner and kick cleanup interval runs every 5 minutes on every room
- Location:
/home/david/code/website/server/multiplayer/ServerMultiplayerRoomMixin.js:50 - Current behavior:
setInterval(this.cleanupExpiredBansAndKicks.bind(this), 5 * 60 * 1000)on every room. With 100+ rooms, that's 100+ intervals. - Proposed change: Use a single global timer or increase interval to 30min.
- Estimated win: <1% CPU reduction.
14. Payload size for connection-acknowledged message is large
- Location:
/home/david/code/website/server/multiplayer/ServerMultiplayerRoomMixin.js:140-162 - Current behavior: Sends
this.playersandthis.teamsobjects (potentially 100+ player objects), plus fullqueryandcategoryManagerstate. - Proposed change: Only send essential connection data; lazy-load room state on demand.
- Estimated win: 10-20% faster initial connection, lower WebSocket churn on join.
Numbers I gathered
Bundle / File sizes
- Client total: ~740 KB across client/play, client/admin, client/db, client/tools, client/user, client/scripts
- Largest single file:
/client/play/mp/MultiplayerTossupBonusClient.js— 32.9 KB (not minified as part of client/) - Server code: Total ~700 KB across database, routes, server, quizbowl
- Largest: ServerMultiplayerRoomMixin.js (18.3 KB), QuestionRoom.js (13.8 KB)
- No minified JS bundles found in client/.min.js** — webpack config is set to production mode and outputs to
[name].min.jsin client/, but these don't appear to be built. This could mean: (1) webpack hasn't been run, (2) the dist files are in .gitignore, or (3) they're built at deploy time.
Query patterns observed
- 18 instances of
.toArray()across database functions — used heavily in aggregations. Good practice to avoid memory bloat, but toArray() itself buffers into memory anyway. - No explicit index definitions found in the codebase — indexes must be created manually in MongoDB Atlas console or via a setup script.
- 6 console.log statements in ServerMultiplayerRoomMixin (connection logging, ban/kick logs, rate limit logs). Moderate volume, not excessive.
Startup / Database
database/databases.js:10hasawait mongoClient.connect();at module load — blocks startup until DB is reachable. On cold starts, this could add 1-5 seconds if DB is in a different region or slow.- No migrations or index creation scripts found. Indexes are presumed to exist or be created manually.
What looks well-tuned already
Positive patterns:
- Multiplayer rooms use Promise.all() to parallelize queries —
getTossupQueryandgetBonusQueryrun in parallel in/home/david/code/website/database/qbreader/get-query.js:144. - Random tossup/bonus endpoints use $sample aggregation stage — avoids sorting and is ~3-4× faster than the general query with randomize option.
- Webpack is in production mode — should minify and tree-shake, though the output isn't visible in the repo (likely built at deploy time).
- Code-splitting is configured — webpack has separate entry points for tossups, bonuses, multiplayer, db explorer, etc.
- Express CORS is only on /api — doesn't add overhead to HTML routes.
- Room cleanup on disconnect is present — players are removed (or marked offline) to prevent unbounded memory growth.
- WebSocket payload size limit enforced —
WEBSOCKET_MAX_PAYLOAD = 10 KBin server.js to prevent huge messages. - Async/await throughout — no blocking I/O in hot paths except ssi-middleware.
- Rate limiting per socket in multiplayer — basic protection against message spam.
Recommendations by priority
| Priority | Issue | Action | Est. Effort | Est. Impact |
|---|---|---|---|---|
| 🔴 High | No text/wildcard indexes on search fields | Create MongoDB indexes; consider search redesign | Medium | Very High |
| 🔴 High | Sync file reads in ssi-middleware | Cache rendered HTML; use async reads | Low | High |
| 🔴 High | Idle room timers keep running | Add checks to stop timers when no players | Low | High |
| 🔴 High | No HTTP cache headers | Add Cache-Control to static routes | Low | High |
| 🟡 Medium | Large state broadcasts over WebSocket | Implement delta updates | High | Medium |
| 🟡 Medium | Leaderboard scans entire stats collections | Add caching + index optimization | Medium | Medium |
| 🟡 Medium | Frequency list doesn't early-limit | Optimize aggregation pipeline | Low | Low-Medium |
Cold Start & Heroku-specific notes
- Startup is blocked by
mongoClient.connect(). On Heroku, if the dyno spins down and has to reconnect, this could add 2-5s to restart time. Consider using a connection pool with faster timeouts or a cloud database closer to the Heroku region. - No health-check endpoint found. Heroku will restart the dyno if it doesn't respond to HTTP within 60s. A simple
/healthendpoint that returns early (before querying the DB) would help. - Morgan logging is off in production, which is good (reduces disk I/O).
Design & UX Audit
Scope: client/, scss/, and the SSI partials in client/ssi/. Pages
sampled: home (/), tossups (/play/tossups/), bonuses (/play/bonuses/),
multiplayer lobby (/play/mp/), multiplayer room (/play/mp/room.html),
database search (/db/), account pages (/user/*), about, settings, 404.
Implemented (committed)
-
Night-mode navbar background and logo —
scss/themes/night.scss:6,193— In night mode the navbar kept its light-blue#dfebfbbackground (declared once unconditionally inscss/custom.scss:111) and the "Reader" portion of the logo was hardcoded#000000. The result is a glaring light-blue stripe across the top of an otherwise dark page, with a black wordmark that's only legible because the navbar is light. Added a dark slate background, a light navbar-toggler SVG, and a light-grey logo suffix for night mode. Commite3cae38c. -
Labels and screen-reader text for icon-only controls —
client/play/tossups/index.html:149-165,client/play/mp/room.html:197-204,client/play/bonuses/index.html:155-158,client/ssi/nav.html:47-51,client/db/index.jsx:382,401,460— Icon-only buttons (pause, settings gear, theme dropdown) had no accessible name. Addedaria-labeland marked the decorative iconsaria-hidden="true". Switched the database loading spinner's hidden SR text fromd-none(which removes it from the a11y tree) tovisually-hiddenand addedaria-live="polite". Changed the "I was wrong" anchor on the tossup page (<a>with nohref— not keyboard focusable) to a proper<button>. Replaced the placeholderaria-label="Default select example"on report-question modals with a real label and fixed the "tossup nagivation" / "bonus nagivation" typos. Commit777a329f. -
Empty state for multiplayer public room list —
client/play/mp/index.js:50-87— When all public rooms have zero online players (the default-hidden case), the "check out a public room"<ul>rendered as a blank gap, leaving users unsure whether the page had loaded. Added a muted, italic empty-state row that explains the situation and points to the "Show empty rooms" toggle; the row is auto-hidden when that toggle flips on. Commit3e325b90(a follow-up commit272f0b0areverts an unrelated WIP edit toserver/multiplayer/ServerMultiplayerRoomMixin.jsthat was sitting in the working tree and got pulled in accidentally). -
autocompletehints on every account form field —client/user/login.html,client/user/signup.html,client/user/forgot-password.html,client/user/reset-password.html,client/user/edit-password.html,client/user/edit-profile.html— None of the username/email/password inputs declaredautocomplete, so password managers and browser autofill had to guess (often wrongly: 1Password tended to fill the username slot with an email). Added the WHATWG tokens (username,email,current-password,new-password) plusautocapitalize="none"/spellcheck="false"on username and email fields so mobile keyboards stop helpfully capitalizing the first letter. Commit119a01f7.
Proposed (not implemented)
Quick wins
-
.checkbox-menuhardcodes white in night mode —scss/dropdown-checklist.scss:7-41— Background#fff !importantand text#333are baked in, so the Difficulty/Category dropdown popovers look like white rectangles on the dark page. Replace with Bootstrap CSS vars (--bs-body-bg,--bs-body-color,--bs-tertiary-bg) and theme-aware hover/active backgrounds. Effort: ~20 min, no behavioral risk. -
.text-highlightquery highlight is invisible in night mode —scss/custom.scss:122-125—background-color: #f8ff00; color: $dark;is fine on white but the bright yellow with $dark text fights with the body. Acceptable, but consider a softer amber (background-color: rgba(248, 255, 0, 0.35); color: inherit;) inside a@include color-mode(night, true)block. Effort: 5 min. -
funny-toastuses inlinestyle="background-color: yellow"withtext-danger—client/play/tossups/index.html:23,client/play/mp/room.html:22— Red-on-yellow has ~4.0:1 contrast at the heading weight used (<h1>), borderline for WCAG AA, and the inline style overrides theme styling. Move to a.toast-funnyclass withbg-warning text-dangerBootstrap utilities, or pick a darker red. Effort: 10 min. -
Page Not Found has no actual help —
client/client/404.html— The page is just a quizbowl question about HTTP status codes followed by the answerline. Charming, but a user who hits a 404 by mistyping a URL has no link back to anything ("Home", "Search the database", "Browse sets"). Add a primary "Go home" button and a list of common destinations under the Easter-egg question. Effort: 10 min. -
Reset/edit profile success path is
window.alert—client/admin/question-management/*.js,client/admin/category-reports/index.jsx:116,155,client/play/geoword/division/index.js:35and 20+ other locations — All admin and a couple of player error messages usewindow.alert, which blocks rendering and looks like a phishing prompt on Chrome. Reuse the existingbootstrap.Toastpattern (#star-toast,#clipboard-toast) and emit a transient toast instead. Effort: a couple of hours; defer until a "toast helper" is extracted. -
Multiplayer lobby
room-nameinput has no<label>—client/play/mp/index.html:23-25— The input that names a new room has no associated label, only adjacent prose ("Create/Join a Room:"). Wrap it in aform-floatinggroup or add a visually-hidden label. Effort: 5 min. -
#multiplayer-nameinsettings/has bare label —client/settings/index.html:43-45— Noform-labelclass, no help text explaining that this is the persistent display name across multiplayer rooms. Fix label and add a 1-line.form-texthint. Effort: 5 min. -
Tossup/bonus button row uses absolute
position-fixedatbottom-0—client/play/tossups/index.html:156,client/play/mp/room.html:192— The Next/Buzz bar floats above content withposition-fixed. On phones in landscape, this eats ~80px of vertical space and can hide the answer text. Considerstickywithsafe-area-inset-bottompadding so it tucks above the iOS home indicator. Effort: 20 min, needs phone testing. -
<a>elements used as buttons throughout — many inclient/db/index.jsx(the "Jump to bonuses" links, pagination<a href="#">items, TXT/CSV/JSON download links use<a className='clickable' onClick={...}>with nohref) — These aren't keyboard-focusable without manualtabindexand aren't announced as buttons. Migrate to<button class="btn btn-link">or addrole="button" tabIndex={0}+ key handlers. Effort: ~1 hour. -
Pagination "active" page has no
aria-current="page"—client/db/index.jsx:421,480—.activeis a visual cue only. Screen readers announce all 10 page numbers identically. Addaria-current={isActive ? 'page' : undefined}. Effort: 2 min. -
<button id="bd-theme">dropdown has noaria-expanded/aria-haspopup—client/ssi/nav.html:47— Bootstrap addsaria-expandedviadata-bs-toggle="dropdown"so this is partial; an explicitaria-haspopup="menu"would help VoiceOver announce the type of dropdown. Effort: 1 min. -
Long question text has no max-width — Question body uses the
container-fluidwidth minus a 25%-left sidebar (≈ viewport - 350px). At 1080p that's ~80 ch which is fine; at 4K it's ~150 ch which hurts readability. Constrain#questiontomax-width: 75ch(ormax-width: 80chwith high-contrast mode). Effort: 5 min.
Bigger projects
-
Coherent design tokens — There are two themes (
light.scss/night.scss) plus three custom CSS vars (--logo-prefix-color,--logo-suffix-color,--user-score-background) and a smattering of hardcoded hex values (#dfebfbnavbar,#f8ff00highlight,#cbcbffcheckbox active,#4A90E2user score). A short pass to extract these into named CSS custom properties under each color mode would make future theming (a high-contrast mode? a darker AMOLED variant?) a one-file change. Sketch: introduce--qb-highlight-bg,--qb-highlight-fg,--qb-card-active-bg,--qb-user-score-bg,--qb-checkbox-active-bg, set them in both@include color-modeblocks, replace literal hex values throughout custom SCSS. ~2-3 hours; needs visual review across all pages. -
Settings page is just a list of toggles —
client/settings/index.htmlis six form-checks and two ranges with paragraph-of-text help underneath each. Could become a card-grid with grouped sections (Appearance, Gameplay, Multiplayer) and preview chips ("Aa" sample for font size, mini timer for high-contrast). Effort: half a day; pure UI, no behavior change. -
Pre-game configuration sidebar on /play/tossups is overwhelming — 17 form-checks, 2 range sliders, 3 buttons, a year range, a set picker, and a mode dropdown — all unfiltered, all on by default in the sidebar. Suggest collapsing rare settings (rebuzz, stop on power, show set name, type-to-answer) behind an "Advanced" accordion or a "More options" link, and surfacing the 4-5 most-used controls up top. Effort: 1-2 days; needs user input on which toggles are rarely changed.
-
Multiplayer room creation flow —
client/play/mp/index.html:21-39is "type a name, click Go". You only learn about controlled vs. private vs. login-required rooms once you read the small print underneath. A 3-button picker ("Public", "Private", "Controlled") with one-line descriptions would make the social settings legible up front. Effort: half a day. -
Mobile play view (
/play/tossups/) — The sidebar collapses under the question on<lgscreens, but you must scroll past 100+ lines of settings to get to the question. There is atoggle-settingsbutton that'sposition-fixedat the bottom, but on first paint the settings are open. Default-collapse on mobile (with the gear showing "Settings" copy), and surface only the timer + answer field + question text. Effort: 1 day; needs to coordinate with the open/close UI inindex.jsx.
Accessibility report
Findings by severity.
High
- Icon-only buttons missing accessible names — fixed in commit
777a329f(pause/settings/theme). <a>withouthrefused as button ("I was wrong", database download links, "Jump to bonuses") — fixed for the tossup case in777a329f; database links remain.- Inputs lack
autocomplete— fixed in commit119a01f7. - Loading spinner SR text was
d-none-hidden — fixed in777a329f.
Medium
- Difficulty/category dropdown menu lacks dark-mode contrast
(
.checkbox-menu, see Proposed quick wins). - Pagination active state is purely visual (no
aria-current="page"). - Color contrast: red-on-yellow
funny-toastis borderline on AA at the<h1>weight — needs verification. - Form labels missing from multiplayer room-name input and the settings page username input.
- Tooltips on buttons (
data-bs-toggle="tooltip") do not work on touch devices (global.js skips them viaisTouchDevice()). Users on tablets see no shortcut hints. Show shortcut chars as small text on the button itself, or in a "?" affordance.
Low
- Modal
tabindex="-2"/-3/-4"values are unusual — Bootstrap doesn't need them;tabindex="-1"is the convention for programmatically-focused modal containers. Won't affect users but confusing to read. <ul>containing<li>"nav-item dropdown" innav.html:14-25—.nav-itemwrappers are direct children of a<div>not a<ul>in this markup. Bootstrap renders fine, but semantically the nav list is malformed. Wrap top-level items in<ul>.- Modal close buttons share IDs —
report-question-closeappears twice inclient/db/index.html(lines 75, 98, 120). Multiple IDs in one document, technically invalid HTML.
Mobile/responsive findings
- The fixed-bottom button bar (
position-fixed bottom-0) on/play/tossups/and/play/mp/room.htmloverlaps the answer area on iPhone landscape and below the iOS home indicator on portrait. Addpadding-bottom: env(safe-area-inset-bottom);to the bar's container. - Database page query form has 4
col-6items on a row plus a set-name input that wraps narrowly on phones. The "Categories" button often slips below the set-name input awkwardly. Fine but could useflex-wrapstyling. - Singleplayer tossup settings sidebar dominates the viewport on mobile — see Proposed bigger projects.
- Hamburger toggler is
.navbar-togglerwith noaria-labeltext beyond the icon — actually it does havearia-label="Toggle navigation"already. OK. - Touch target sizes:
.theme-toggleand the dropdown carets are ~30×30px, below the iOS 44pt guideline. Minor. #timerin the sidebar is250%body font on the timer face — that becomes ~32-40px which is fine; just verify it doesn't wrap at very narrow widths.
Notes
The lint script (npm run lint) runs semistandard --fix, which
made small autofixes to files unrelated to this audit while I worked
(server/database files in the working tree). Those unstaged fixes are
genuinely good (one is a real operator-precedence bug in
database/qbreader/get-packet.js); they were authored by another
session that already committed similar fixes on this branch. I avoided
touching them. If npm run lint is part of the regular workflow,
expect those autofixes to appear in subsequent commits.
Feature Proposals for QBReader
Top Picks (Start Here)
1. Personalized Question Recommendation Engine [M]
- What: After each practice session, suggest next questions to study based on recent misses, weak categories, and time since last attempt. Algorithm filters out recently-seen questions and prioritizes user's historical weak spots.
- Why: Competitive quizbowlers practice with purpose. Currently users must manually decide what to study; this removes decision fatigue and optimizes practice time toward tangible improvement.
- Sketch:
- Backend: Extend
/database/account-info/user-stats/with a newget-weak-categories.jsandcalculate-recommendation-score.js - API endpoint:
/api/recommendations— queriesperTossupDataandperBonusDatawith filters on (1) user's lowest accuracy by category, (2) time-since-last-seen > 7 days, (3) difficulty match - Frontend: New
/client/play/recommendations/with card UI showing "Today's Study Plan" (top 3 recommendations) - Database: Add
lastAttemptDateto user play history (already have created dates) - Risks: Algorithm may over-recommend niche categories if user only misses 1–2 questions; mitigate by blending accuracy % with attempt count. Privacy: ensure recommendations don't leak other users' weak spots.
- Effort: M (3–4 days) — mostly aggregation pipeline tweaks + new UI card.
2. Post-Game AI Tutor (Claude API) [M]
- What: After a solo practice round, optional AI-powered recap that explains why you missed questions, highlights the clue chain logic, and gives "what you should have known" meta-feedback.
- Why: Solo practice lacks the human coach feedback competitive players get. This bridges that gap and teaches question-solving metacognition. High emotional payoff (players feel guided) with low friction (optional, not intrusive).
- Sketch:
- API: New
/routes/api/ai/tutor.jsthat accepts{ userId, roundResults: [{questionId, userAnswer, correctAnswer, category, missed}] } - Claude integration: Use Sonnet for mid-tier reasoning (quality/speed tradeoff). Prompt caching on system instructions + question database context (category hierarchies, question difficulty).
- Design:
POST /api/ai/tutorstreams back paragraphs via Server-Sent Events (SSE) so UI shows live typing feedback - Frontend: Modal that appears after round end with "Get AI Feedback?" button → streams response into collapsible sections per question
- Cache strategy: Cache question metadata (category, difficulty, clue concepts) per question ID in Claude prompt to avoid recomputing on repeated requests
- Risks: Claude API costs ($, need rate limiting + user quota). Response latency (3–5s) — acceptable async, poor if synchronous. Question context may be too long (prompts can't exceed 200K tokens but caching helps). Mitigate: add per-user feedback quota (e.g., 5/day free, then upsell).
- Effort: M–L (4–6 days) — API plumbing, prompt engineering, SSE streaming, error handling.
3. Spaced Repetition Queue for Missed Clues [M]
- What: Anki-style deck that resurfaces questions you got wrong, with interval-based scheduling (see again tomorrow, in 3 days, in 2 weeks, etc.). Focuses practice on your specific weak points.
- Why: Quizbowl knowledge is deep; one pass isn't enough. Spaced repetition (proven learning technique) applied to QBowl can significantly increase long-term retention. Competitive players will pay for this.
- Sketch:
- Schema: Add
reviews: [{ interval, nextReviewDate, easeFactor }]subdoc to eachperTossupDataandperBonusDatarecord (OR create separateuserReviewQueuecollection) - SM-2 algorithm: Track success/failure per question; adjust
nextReviewDate(naive start: 1d → 3d → 7d → 14d; adjust based on difficulty) - Frontend: New
/client/play/reviews/page listing today's reviews, filtered by category/difficulty. Play them like a normal practice round, but tag "Saw this before" - API:
/api/reviews/queue(GET today's batch),/api/reviews/record-attempt(POST ease/success) - Database: Extend
/database/account-info/user-stats/withgetReviewQueue.js,updateEaseFactor.js - Risks: Users might ignore it if UX is clunky; must feel as smooth as main play. Interval calculation can be math-heavy; test with sample users first. Privacy: don't expose other users' reviews.
- Effort: M (3–5 days) — review queue logic, SM-2 scheduling, UI for review deck.
4. Granular Time-Series Stats: Power/Neg/Conversion by Category Over Time [M]
- What: Extend existing stats page to show graphs of "20% power rate in science (last 90 days)", "8% neg rate in history", "conversion % trending up" with per-category breakdowns. Let users slice by difficulty, set, multiplayer/solo, and time window.
- Why: Intermediate players want to track growth. Currently QBReader shows aggregate (e.g., "50 tossups, 40% correct") but no trend. Players studying for tournaments want to know "Am I getting better at neg control?" or "Which category improved most?"
- Sketch:
- Extend
/database/account-info/user-stats/get-tossup-graph-stats.jsto add more fine-grained groupings: already groups bycreateddate; add subcategory-level buckets, addnegRate,conversionRatecalculated fields - Frontend: New chart library (Chart.js or Recharts); replace
/client/user/stats/page with tabbed interface (Summary → Trends → By Category → Leaderboard Rank) - New UI components: Category dropdown, date range picker (already exist in
/client/play/year-slider.js), metric selector (pptu / power rate / neg rate / conversion %) - Database: Aggregation already fairly efficient (perTossupData); may need indexing on
category,subcategory,created - Risks: Too much data can overwhelm; UX must guide user to actionable insights (e.g., "Your science neg rate spiked; consider easier sets?"). Aggregation pipeline complexity increases.
- Effort: M (3–4 days) — mostly UI + one new aggregation branch.
5. Embeddable "Question of the Day" Widget [S]
- What: Snippet of code (iframe or async script) schools/blogs/Discord servers can embed to show a rotating daily tossup/bonus. Links back to QBReader for full practice. Lightweight, no auth needed.
- Why: Viral growth + community engagement. Teachers use it; QBowl clubs share it; drives organic traffic. Low effort, high visibility ROI.
- Sketch:
- API endpoint:
/api/public/qotd— returns { question, answer, category, difficulty, date, linkToFull } - Widget JS:
/client/public/qotd-widget.js— embeddable script tag; client injects iframe with HTML from/client/public/qotd-widget.html - CORS: Allow public requests (no auth needed)
- Database: Simple — just pull today's random question (or curate a fixed pool of "good QOTDs" and rotate)
- Risks: If widget is unstyled or loads slow, will hurt QBReader brand. Mitigate: thorough testing, lazy-load assets. May attract low-engagement traffic (people who just see QOTD, never play).
- Effort: S (1–2 days) — endpoint + widget script, minimal DB changes.
Solid Second Tier
6. Tournament Mode: Bracketed Multiplayer Rooms [L]
- What: Admins can create tournament brackets (8–64 players) where teams face off in timed rooms, scores carry forward, winners advance. Simul-style with all rooms reading same questions simultaneously.
- Why: QBowl organizations run invitationals weekly; currently they use external tools (QEMS, PACE). If QBReader supports tournaments, becomes platform of choice. High switching cost (their data moves here).
- Sketch:
- Backend: New
ServerTournamentRoom.jsextending multiplayer mixin. Track tournament state: registration → bracket generation → room scheduling → live results → final standings - Database: New
tournamentscollection; tournament owns array ofmatches, each match is aServerTossupBonusRoom - Admin UI:
/client/admin/tournament/— create bracket, assign teams, lock registration, start rounds - Multiplayer: Each room is standard tossup/bonus but sync'd across simultaneous matches (read same questions, same timer)
- API:
/api/admin/tournament/{tournamentId}— CRUD, bracket generation (Swiss, double-elim, round-robin) - Results: Per-team standings page; exports CSV for PACE/QEMS import
- Risks: Complex bracket logic, cheating potential (teams could see other team's answers), large concurrency spike. Mitigate: auth token per team, rate-limit question fetches, stress-test with 100+ concurrent players.
- Effort: L (1–1.5 weeks) — tournament logic, bracket algorithms, UI, real-time sync, admin panel.
7. Audio Reading (TTS) for Solo Practice [M]
- What: Text-to-speech reads tossup questions aloud at configurable speed (simulating live reader). Players practice buzzing to audio, not text, matching real tournament conditions.
- Why: Online practice feels different than live tournaments (hearing vs. reading). This bridges that gap. Especially valuable for younger/newer players who aren't used to hearing questions.
- Sketch:
- Use Web Speech API or external TTS (e.g., Google Cloud TTS, ElevenLabs for higher quality). Start with Web Speech API (free, no infra).
- Frontend:
/client/play/TossupClient.jsalready hasaudio.jsfor sound effects; extend withspeakQuestion()method - UI: Toggle "Read Aloud" on tossup page; play/pause controls; reading speed slider (already have one in code:
reading-speed) - Sync: TTS playback must align with word-by-word question rendering (use playback events to trigger next word)
- Optional: Store TTS audio files for popular questions to avoid regenerating (cache in
/docs/or S3) - Risks: Web Speech API latency (300–500ms delay to start speaking). Browser support varies. ElevenLabs/Google TTS adds cost. Pronunciation errors (names, accents). Mitigate: user can fall back to reading text; offer "practice mode" (async) vs. "match mode" (must react to audio).
- Effort: M (3–5 days) — TTS integration, sync playback with rendering, UX polish.
8. Public Leaderboards + Season Ladders [M]
- What: Global/category-specific leaderboards (top 100 players by tossup accuracy, bonus ppb, etc.). Seasonal resets (per tournament season). Profile cards show rank, badge, best streak.
- Why: Gamification drives engagement. Competitive players want recognition. FOMO + prestige = retention. Easy to implement given existing leaderboard code.
- Sketch:
- Backend: Extend
/database/account-info/leaderboard.jsto accept filters (category, difficulty, season, metric: tossup_acc / ppb / pptu) - Seasons: Add
seasonfield to user records (auto-set to current season on play; reset on season start) - Frontend: New
/client/leaderboard/page (already have one at/client/admin/leaderboard/— refactor into public version) - Profiles: Extend
/client/user/to show rank badge, "Top 5 Categories", "Best Streak", "vs. Friend" comparison - API:
/api/leaderboardswith pagination, filtering (category, difficulty, season, sort by different metrics) - Database: May need indexing on
(season, metric, rank)for fast queries - Risks: Leaderboards can be demotivating for casual players (always at bottom). Mitigate: show friend-relative standings, bracket-based rankings (top 1%, top 10%, etc.).
- Effort: M (3–4 days) — mostly aggregation + UI, minimal new logic.
9. Bot Opponents for Solo Multiplayer Feel [L]
- What: Multiplayer rooms can spawn AI opponents with configurable difficulty (Beginner/Intermediate/Advanced). Bots buzz based on clue strength heuristics, make realistic mistakes. Solo players get competitive pressure.
- Why: Multiplayer is more fun than solo, but hard to find players at 3 AM. Bots fill that gap without forcing players to wait. Retention boost.
- Sketch:
- Bots as
ServerPlayerinstances (existing code in/server/multiplayer/ServerPlayer.js); add subclassBotPlayer - AI logic:
BotPlayer.decideToBuzz()analyzes clue position (% through question), question category (how well-trained?), difficulty tier. Use simple heuristics initially (don't need ML). - Config: Room setting "Add Bot Opponent?" with difficulty slider; bots can answer ~60–90% depending on difficulty
- Bots don't save stats to user leaderboards (clearly marked "vs. Bot")
- Frontend: When creating room, checkbox "Include Bot?" + difficulty picker
- Training: Bots more likely to buzz on questions in their "expert" categories; less likely on random weak categories (more realistic)
- Risks: Unfair if bot answers are too perfect; players might complain. Bad AI makes solo multiplayer feel even worse. Mitigate: expose bot difficulty, let players adjust, gather feedback.
- Effort: L (1–2 weeks) — heuristic design, playtesting, tuning curves.
10. Spectator Mode for Multiplayer Rooms [M]
- What: Users can join a room as spectators (read-only), watch live play without affecting scores. Useful for coaching, learning, tournaments.
- Why: Teachers want to watch students play. Tournament organizers need live scoreboard for projection. Streaming becomes possible (stream spectator feed). Feature gap vs. competitors.
- Sketch:
- Backend: Extend
ServerTossupBonusRoomto track spectators (separate from players); spectators receive all broadcasts but can't buzz/answer - Auth: Spectators can join public rooms freely; private rooms require password or invite
- Frontend: "Join as Spectator" button on room list; spectator UI shows scoreboard + live question + chat but disabled buzz/answer
- API:
/api/multiplayer/spectate?roomName=...returns room state as read-only connection - Dashboard: Room creators can see spectator count
- Risks: Extra bandwidth (each spectator = extra WebSocket connection); message broadcast needs to avoid flooding. Mitigate: batch spectator updates every 500ms instead of per-event.
- Effort: M (2–3 days) — WebSocket plumbing, permission checks, read-only UI.
Ambitious / Longer-Term
11. Question Authoring/Editor UI for Writers [L]
- What: Community contributors can write/edit questions via web form (tossup: question + answer + category + difficulty; bonus: lead-in + 3 parts + answers + values). Submissions go to moderation queue. Accepted questions join the database.
- Why: User-generated content scales the question pool. Currently limited to published sets. Community feels invested.
- Sketch:
- Frontend:
/client/tools/question-editor/— form with fields for question text (rich HTML), answer (with tagging for underline/brackets), category tree selector, difficulty slider - Validation: Answer checker (already use
qb-answer-checkernpm pkg); enforce format (ANSWER: text, with highlighting) - Backend:
/routes/api/submit-question— stores tosubmissionscollection, sends notification to admins - Admin panel:
/client/admin/submissions/— review form, approve/reject with feedback, auto-populate database on accept - Database: New
submissionscollection with fields: content, author_id, created, status (pending/approved/rejected), admin_feedback - UX: Real-time preview of question as you type
- Risks: Spam/inappropriate submissions; quality control burden on admins. Mitigate: require user reputation (e.g., 50+ correct answers) to submit, approve before publishing, use NLP to flag questionable content.
- Effort: L (1–2 weeks) — form validation, preview, moderation workflow, admin tooling.
12. Discord OAuth + Result Posting [M]
- What: Users can log in with Discord; after practice rounds, optional "Share Score to Discord" button posts results to a designated server channel (e.g., "qbreader-scores" webhook). Also check Discord roles to auto-assign user roles (e.g., "Senior" players get access to tournament bracket).
- Why: Quizbowl communities live on Discord. This embeds QBReader into their workflow and drives engagement (friends see your scores, compete).
- Sketch:
- OAuth: Extend
/server/authentication.jsto support Discord OAuth (add Discord Passport strategy) - Backend:
/routes/auth/discord— redirect to Discord, get user info, create/link account - Discord webhook: Store webhook URL per user/room; on round end, POST score summary (player name, category, accuracy, timestamp, link to full stats)
- Role syncing: Fetch user's Discord roles; if "College Captain", grant premium features (optional UX enhancement)
- Frontend: "Share Score" button in results page; optional Discord role display on profile
- Permissions: Request
identify,guildsscopes - Risks: Discord ToS compliance (don't spam channels). Webhook could be abused. Users might revoke Discord app access later. Mitigate: one webhook per user (they control), rate-limit posts, allow opt-out.
- Effort: M (2–3 days) — OAuth plumbing, webhook integration, UX.
13. Better Question Reporting + Errata Workflow [S]
- What: Expand
/routes/api/report-question.js— make a public queue of reported questions (for admins), auto-group duplicate reports, notify mods with stats (how many reports, which users, what reasons). Mods can mark as "errata" with correction, which displays to all subsequent players. - Why: Currently reports go into a black box; users don't know if their report was seen. Errata corrections help future players avoid confusion. Builds community trust.
- Sketch:
- Frontend: Report form already exists; enhance with predefined categories (Wrong Answer, Ambiguous, Typo, Unfair Difficulty) and optional description field (mostly done)
- Backend: Extend database schema — add
reports: [{ userId, reason, description, created }]subdoc to question document - Admin page:
/client/admin/question-reports/— table of reported questions, grouped by ID, shows report count, reasons, user comments; mods can mark as "acknowledged" or "resolved" - Errata field: If question marked as errata, add
errata: { correctedAnswer, explanation, resolvedDate, moderator }to question doc - Frontend: After playing, if question has errata tag, show banner "This question had an errata: [correction]"
- API:
/api/admin/question-reports(list),/api/admin/question-reports/{id}/resolve(POST errata correction) - Risks: Moderate volume of spam reports (mitigate: require login, rate-limit per user). Mods might be slow to respond (set up reminder notifications).
- Effort: S–M (2–3 days) — mostly schema + admin UI.
Considered But Not Recommended
Mobile App (Native iOS/Android)
- Why skip: Web already responsive; PWA (see below) gives 80% of the value for 10% of effort. Native app demands 2+ platforms, separate codebases, app store review overhead. Not worth it unless QBReader is already at 100K+ users.
AI Question Generation (Synthetics)
- Why skip: Quizbowl knowledge is deep and specific. Claude-generated questions would often miss historical nuance, proper noun variants, and idiomatic phrasings that real tournament questions have. Community would reject synthetic questions as "wrong feel." Better to invest in community submissions (Editor UI) instead.
Real-Time Classroom Mode (Teacher Controls)
- Why skip: Niche use case; can be addressed by spectator mode + manual room control. Tournament mode is higher ROI (addresses paying organizations). Classroom often happens offline anyway.
Mobile PWA (Install to Home Screen)
- Why skip: This is good, but lower priority than top picks. After top tier features land, revisit. Web app already works on mobile; PWA just makes it look more native. Implementable in S (just manifest.json + service worker), but not as valuable as tutor or recommendations.
Implementation Roadmap Suggestion
Phase 1 (Weeks 1–2): Quick wins + foundational 1. Embeddable QOTD widget (S) — easy viral growth 2. Personalized recommendations (M) — high engagement ROI 3. Better reporting + errata workflow (S–M) — trust & community
Phase 2 (Weeks 3–6): Depth + engagement 1. Post-game AI Tutor (M) — premium feature, Claude integration 2. Spaced repetition queue (M) — retention mechanism 3. Time-series stats (M) — analytics depth
Phase 3 (Weeks 7–10): Social/Competitive 1. Public leaderboards + seasons (M) — gamification 2. Spectator mode (M) — tournament readiness 3. Bot opponents (L) — solo multiplayer
Phase 4 (Weeks 11+): Ecosystem 1. Tournament mode (L) — capture organizations 2. Question authoring UI (L) — crowdsource content 3. Discord integration (M) — embed in community 4. Audio TTS (M) — match real tournament feel
Claude API Notes
For AI Tutor (Proposal #2)
- Model: Sonnet (best balance of speed/quality for live feedback). Could use Haiku for quick summaries, Opus if you want highest-quality explanations (slower, pricier).
- Prompt Caching: Cache question metadata + category hierarchies + difficulty context at the system level. Reuse across all users (10K cache hits/user if 1000 active users).
- Estimated cost: ~$0.02–0.05 per round feedback (depends on round length, model choice, caching hit rate). Budget: ~$5k/month at scale for 100K active users if 10% use tutor 2x/week.
- Implementation: Stream responses via Server-Sent Events (SSE) so feedback appears to user in real-time. Store feedback in MongoDB for future reference/analytics.
For Recommendations (Proposal #1)
- Model: Haiku (simple rule-based logic; no need for heavy reasoning). If you want to get fancy with personalization, Sonnet.
- No caching needed: Recommendations are user-specific, generated fresh per session.
- Cost: Minimal ($0.001–0.005 per request if batched).
Quick Implementation Checklist
- [ ] Audit existing
/database/account-info/user-stats/for recommendation score calculation - [ ] Plan Claude API integration (keys, rate-limiting, quota enforcement)
- [ ] Sketch out spaced repetition SM-2 algorithm (SM-2 is open-source, well-documented)
- [ ] Gather UX feedback on existing stats page (what confuses users?)
- [ ] Profile WebSocket broadcast performance (spectator mode will stress this)
- [ ] Test bot AI heuristics with real quizbowl players (hardest part of bot feature)
Architecture Review: qbreader/website
Architecture Map
Deployment topology: Single Node.js dyno on Heroku with daily 8 AM UTC restart. MongoDB Atlas cluster stores questions and user accounts. Static assets served via Express.
Key components:
- Entry point (
server.js): HTTP server + WebSocket server (ws library) both on same port - Express app (
app.js): Middleware stack: trust proxy → hostname/HTTPS enforcement → cookie sessions → rate limiting → IP filtering → routes - Routes (
routes/): /api/*: Question data, queries, frequency lists, webhooks/auth/*: Login, signup, email verification, password reset/db/*: Question browser, stats/play/*: Static HTML pages for singleplayer/multiplayer/admin/*: Admin tools/user/*: Profile management- Multiplayer layer (
server/multiplayer/): handle-wss-connection.js: Entry point for all WebSocket connections- Global state:
tossupBonusRooms = {}(in-memory map of all active rooms) - Connection per-IP tracking:
connectionsByIp = Map() - Room model:
ServerTossupBonusRoomextends mixins applied toTossupBonusRoom - Mixin chain:
ServerMultiplayerRoomMixin(TossupBonusRoom)→ TossupBonusRoom inherits fromBonusRoomMixin(TossupRoomMixin(QuestionRoom)) - Game logic (
quizbowl/): Shared pure classes (Room,QuestionRoom,TossupRoom,BonusRoom) for room state and game rules - Database (
database/): MongoDB client singleton (databases.js). Three DBs:qbreader(questions),account-info(users),geoword(geo questions) - Frontend (
client/): Vanilla ES6 + React JSX (built via webpack). No global state manager; state lives in DOM + websocket messages. Local storage for preferences.
What Works Well
- Separation of concerns by layer: Database, routes, game logic, multiplayer, auth are cleanly separated. Easy to find code.
- Shared quizbowl logic: Pure classes in
quizbowl/are reusable across singleplayer (client-side) and multiplayer (server-side). The game rules live once. - Mixin pattern for room variants: Instead of inheritance explosion,
ServerMultiplayerRoomMixincleanly adds server-specific behavior (sockets, bans, votekicks) to the base game room. Extensible. - Connection safety: Checks for duplicate tab login, ban/kick lists, rate limiting, IP-based connection caps all happen at socket entry (
handle-wss-connection.js). No async surprises. - Moderation built-in: IP bans, username filtering (inappropriate strings), room-level locks, login requirements, votekick system all present and centralized.
- Category system:
CategoryManagerprovides flexible category/subcategory filtering across permanent rooms and user-created rooms. - Static content strategy: No database writes for static questions simplifies scaling reads. CDN could cache questions via
/api/queryendpoints.
Smells & Concerns
High Priority
- Entire game state in memory, lost on restart. The
tossupBonusRoomsmap is cleared every 8 hours (Heroku scheduler restart at 8 AM UTC). No room persistence, no graceful shutdown. If the dyno crashes mid-game, 500+ players and all game progress vanish. Reconnect handling assumes the room still exists (handle-wss-connection.js:88), but if a dyno restarts, the room is gone. - Why it matters: Multiplayer games mid-play get brutally interrupted. Reliable room ownership (e.g., auto-save to MongoDB on question answer, restore on restart) would help.
-
Rough fix: Add a "room snapshot" (question state, scores, players) to MongoDB at game milestones, restore on restart. Moderate effort, high reliability gain.
-
Single dyno with no redundancy. Load balancer? Sticky sessions? Not visible in Procfile. If the dyno is down for deploys or crashes, all live games are lost. No graceful WS close with reason codes (close code 3000 is hardcoded in client but never sent from server).
- Why it matters: Any deploy = downtime for all players. One crash = cascade failure.
-
Rough fix: Multi-dyno + sticky sessions via Heroku. Better WS close handling with 1000 (normal) vs 4000+ (server error) codes.
-
ServerMultiplayerRoomMixinis 477 lines and handles too much. Message routing, banning, votekicking, chat, question delivery, leaderboards, room settings, rate limiting all here. If you need to tweak one (e.g., add a new room feature), you risk breaking others. No tests for this file. - Why it matters: High-risk, high-touch code with no safety net. At 10x users, subtle timing bugs in message handling will be hard to debug.
-
Rough fix: Break into focused classes:
RoomMessageRouter,BanManager,RateLimitManager,RoomSettingsManager. Compositional, testable. -
WebSocket reconnect not implemented. Client closes on disconnect with code != 3000 (line 7,
room.jsx) and alerts the user. No auto-reconnect, no resume of game state. If a player's network hiccups for 5s, they're out. - Why it matters: Mobile players, flaky networks, browser tab background tabs = high churn. Reconnect with stored room state (e.g., room name, userId via localStorage) is table stakes for multiplayer games.
-
Rough fix: Client stores
{ roomName, userId, lastMessageId }in sessionStorage. On reconnect, send "resume" message to server with these. Server replies with diff (new messages since lastMessageId). -
No error handling in socket message loop. Line 122-135 in
ServerMultiplayerRoomMixin.js:socket.on('message', ...)catches JSON parse errors but then callsthis.message()with no try/catch. If a message handler crashes (e.g., DB query fails), the socket handler dies silently, breaking that player's connection. - Why it matters: One bad state or race condition in a message handler cascades to that player's socket being stuck.
-
Rough fix: Wrap the
this.message()call in try/catch. Log and send an error message back to the client. Gracefully close socket on repeated failures. -
Rate limiter stores global state in
this.rateLimitExceededSet but never expires entries. If a user exceeds rate limit, they're blocked forever in that room (across restarts? no, restart clears it). But within a room session, a spammer is permanently muted. - Why it matters: False positives (lag spike causes burst) or legitimate recovery aren't possible. Better: per-user exponential backoff or sliding window.
- Rough fix: Use
rateLimiterlib or build a token bucket: track{ timestamp, count }per user, expire entries after 1 min of inactivity.
Medium Priority
- Auth token stored in cookie-session, checked on room join from raw cookie header. Line 103-106 in
handle-wss-connection.js: manually parsereq.headers.cookie, extract base64 session, JSON decode, then callcheckToken(). Fragile. If cookie format changes, login-required rooms break silently. - Why it matters: Hard to maintain, easy to break during express upgrades.
-
Rough fix: Move auth check to Express middleware, pass verified user to WS handler via request context.
-
MongoDB connection pooling settings not visible.
databases.jsuses default MongoClient settings. Max pool size, timeouts, retry behavior are unknown. At 500 concurrent players all querying questions, what's the connection pool doing? - Why it matters: Connection exhaustion could silently fail at scale without clear signal.
-
Rough fix: Log pool stats (connection count, wait time). Set explicit pool size:
maxPoolSize: 50if 500 players means ~100 concurrent queries. -
Frontend state coupling to DOM. QuestionClient and TossupClient manage state by reading/writing DOM directly (e.g.,
document.getElementById('answer-input').value). No single source of truth. Replay, undo, or tests are hard. - Why it matters: Singleplayer/bonus/tossup modes diverge in how they manage state. Harder to add features (e.g., "resume game" persistence).
-
Rough fix: Extract state to a simple JS object, expose getters/setters, keep DOM as a view. Use a test library to verify state transitions.
-
No tests. Find detected zero
.test.jsor.spec.jsfiles. Highest-risk area (multiplayer logic, game rules) is untested. A refactor to mixin-breaking could ship without detection. - Why it matters: Confidence in refactors is zero. Bugs in scoring logic or room state sync aren't caught.
-
Rough fix: Add Jest or Mocha. Start with multiplayer: unit tests for
ServerMultiplayerRoomMixin.message()for each message type. Integration test for full room lifecycle (join → question → buzz → answer → score). -
Heroku Scheduler assumes one-off dyno exists. The 8 AM UTC restart is nice but very coarse. No coordinated shutdown (warn players 1 min before). No migration of games to a new dyno.
- Why it matters: Every restart is a hard cutoff.
-
Rough fix: Add a shutdown hook that waits 30s, sends "server restarting" message to all sockets, then closes. Clients catch the close code 1011 (server error) and show "server is restarting, come back in 2 min."
-
Database query patterns scattered across route handlers. E.g.,
/api/query.jsconstructs MongoDB queries directly. No query abstraction. If schema changes, many files need updates. - Why it matters: Coupling. Hard to refactor DB layer.
- Rough fix: Create a
QueryServiceclass that encapsulates all question queries. Routes callqueryService.findByCategory(), not raw MongoDB.
Low Priority
- Webpack bundles are dev → production, no source maps in production. Debugging errors in minified bundles is a guessing game.
-
Rough fix: Generate source maps, upload to Sentry or similar.
-
Hardcoded Stripe integration hints at payment feature but no visible usage. Is it enabled?
-
Rough fix: Clarify in code or remove.
-
Docker not used locally or in production. Heroku is convenient but ties you to their runtime. If you ever want a different host, you re-learn the setup.
- Rough fix: Low priority for a small team, but a
Dockerfile+docker-compose.ymlfor local dev is a one-time gain.
Scaling Cliffs
- 10x users (5,000 concurrent):
- Single dyno CPU will saturate. At ~1 dyno = 500 concurrent WS, you need 10 dynos minimum.
-
Mitigation: Add sticky sessions load balancer (Heroku's native LB does this). But in-memory
tossupBonusRoomsmust be sharded across dynos or moved to Redis. Huge lift. -
100x questions (1M+):
/api/queryendpoints do full collection scans with filters. MongoDB will slow down. Indexes oncategory,difficulty,setYearexist (assumed from schema) but aren't visible. Aggregation pipeline queries are likely missing.-
Mitigation: Index audit, explain() on slow queries, move complex queries to aggregation pipelines. MongoDB Atlas M5+ cluster for auto-scaling reads.
-
10x rooms (2,000 simultaneous):
- In-memory
tossupBonusRoomsmap iteration (e.g., to broadcast "server restarting") becomes O(n). Cleanup loop runs every 5 min anyway. -
Mitigation: Move rooms to Redis with TTL. Much faster iteration, natural cleanup. Rooms become cluster-aware.
-
Long-lived connections (8-hour restart gap):
- WebSocket memory leaks possible. Each socket holds references to room, player, team objects. If a player joins/leaves 100 times (unlikely but possible in 8 hours), those objects might not be GC'd if there are circular refs.
- Mitigation: Memory profiler on production daily. Watch heap size in New Relic. Add a "room purge" endpoint that deletes empty rooms.
Reliability Concerns
- Partial failure: DB blip during game. If MongoDB is slow,
/api/query(get next question) timeouts. Client shows "loading..." forever. No retry or user-friendly fallback. -
Fix: Add timeout + retry with exponential backoff on all DB calls. Return a cached question if DB is down (degrade gracefully).
-
Dropped WS mid-message. If a socket closes while the server is sending a 10 KB message (max payload set to 10 KB in
server.js), the client gets an incomplete message and breaks state. -
Fix: Message chunking or compression. Ensure max message size is reasonable. Monitor for "frame too large" errors.
-
Player object leak. When a player leaves without activity, their object is kept in
this.players(see line 45-51 inquizbowl/Room.js—marked as "delete this.players[userId]" but commented out). Over thousands of games,this.playersgrows indefinitely. -
Fix: Uncomment the delete, or add a TTL: delete players 1 hour after they go offline.
-
Concurrency: duplicate room creation. Two simultaneous WS connections to the same room name. Line 46-54 in
handle-wss-connection.jschecksif (!tossupBonusRooms[roomName])then creates. If two requests race, both might create. (Unlikely due to JS single-threaded, but cluster mode = risk.) -
Fix: Use
Object.getOwnPropertyDescriptor()or a lock. Or move room creation to a single queue. -
Email verification tokens never expire.
activeVerifyEmailTokensinauthentication.jsis a plain object. No cleanup. If a user requests 100 verify emails, all tokens are valid forever (or until server restart). - Fix: Add a TTL:
activeVerifyEmailTokens[username] = Date.now(), expire after 15 min (already mentioned in comment but not enforced).
Testing Assessment
What's there: None (no test files found).
What's missing:
1. Unit tests for ServerMultiplayerRoomMixin: Each message type (ban, votekick, give-answer, etc.) should have a unit test verifying it mutates room state correctly. Currently, bugs here are caught only in production or manual play.
2. Integration test for room lifecycle: Join → load question → buzz → answer → score → leave. Ensures multiplayer protocol is consistent end-to-end.
3. Auth tests: Password hashing, token generation, email verification token lifecycle.
4. Frontend state tests: TossupClient state transitions (not started → reading → buzz → reveal → score).
5. Load test: Spawn 100 WS connections to a room, measure latency and errors.
Where tests would pay off most:
- server/multiplayer/ServerMultiplayerRoomMixin.js (477 lines, high-risk)
- quizbowl/TossupBonusRoom.js (game rules)
- server/authentication.js (auth is a single point of failure)
- Any route that modifies a user's data
Top 5 Refactors by Leverage
1. Extract message router from ServerMultiplayerRoomMixin (Effort: medium, Impact: high)
Break the 477-line mixin into smaller, testable handlers:
- MessageRouter class: routes message.type to handler methods
- BanManager: votekick, ban, mute logic
- RateLimitManager: per-user token bucket
Each handler is a 20-50 line class with one responsibility. Instantly testable. At 10x users, you can tweak one without fear.
2. Add Redis for room state (Effort: high, Impact: high)
Move tossupBonusRooms to Redis with TTL. Benefits:
- Rooms survive dyno restart (if saved before close)
- Multi-dyno awareness: room is "owned" by a dyno ID, shard player connects to same dyno
- Natural cleanup: rooms expire after 1 hour of inactivity
- Scales to 10x rooms. This is the bottleneck at scale.
3. Implement WebSocket reconnect (Effort: medium, Impact: medium)
Client stores room state in sessionStorage, server stores game state in room. On WS close: - Client waits 1-5s, reconnects with room name + userId - Server matches existing player, resends current game state - Player rejoins without losing progress Gains ~50% of players back after brief disconnects. Simple but high value.
4. Add comprehensive error handling to socket message loop (Effort: low, Impact: high)
Wrap message handlers in try/catch, log, send error to client, optionally close socket. Prevents silent failures. Takes 1 hour, catches 80% of runtime bugs.
5. Extract database access layer (Effort: medium, Impact: medium)
Create QuestionService, UserService classes that encapsulate all MongoDB queries. Routes call questionService.query(), not raw MongoDB. Decouples routes from schema, enables easy swaps (MongoDB → Postgres, for example). Starts with one service, grows to cover all data access.
Summary
qbreader is well-structured for a small team and does the core job (multiplayer quizbowl) reliably for 100-500 concurrent players. The mixin pattern and shared game logic are elegant. But it trades scalability and resilience for simplicity: - In-memory state + daily restart means no redundancy, no graceful failure modes. - No tests mean refactors are risky. - WebSocket reconnect missing means a 5s network blip is game-over. - 477-line mixin is a hidden bomb: hard to understand, risky to change.
For your next 6 months, prioritize: (1) error handling + tests in multiplayer, (2) Redis for rooms (enables multi-dyno), (3) reconnect. These three unlocks scaling to 5,000 concurrent players and survive brief outages. Rewriting from scratch is not needed; steady refactoring works.