QuizNex
AI-powered proctored online examination SaaS for educational institutions — with in-browser TensorFlow.js eye tracking, Google Gemini question generation via LangChain, multi-role dashboards (Student / Teacher / Organization), passkeys, TOTP 2FA, and SSE real-time quiz control.
Solo. I own every layer of this repo: the Postgres schema (23 tables, 16 enums, 7 generated migrations), all 71 API route handlers, the NextAuth v5 edge/node auth split, the browser-side proctoring engine, the Groq question-generation pipeline, the Safepay billing integration, and the AWS Amplify deployment. The git history has a single author. Parts of the security audit and some commit messages were drafted with AI assistance, but the design decisions, the debugging, and the shipping were mine.
// the problem
What was broken
A teacher running an online test has no idea whether the person answering is the person enrolled, or whether they have a second tab open. The workarounds are all bad: proctor over a video call and watch thirty tiles at once, force everyone into a physical room, or accept that the score measures search speed rather than knowledge. Teachers were also hand-writing every question, which is the slowest part of setting a quiz and the part they resent most. Schools that wanted to run this across many teachers had no way to see performance across classrooms without collecting spreadsheets by email.
// constraints
What made it hard
One developer, no budget for a proctoring vendor or a GPU inference service — every detection model had to run in the student's browser on whatever laptop they own, including machines with no usable WebGL backend. Payments had to work in Pakistan, which rules out Stripe and forces Safepay, whose hosted-checkout docs and actual API disagree in several places. Deployment target was AWS Amplify SSR, which runs Next.js routes in Lambda and has its own opinions about which environment variable names it will pass through. And the users are teachers and students, not operators: anything that needs a manual step, a cron job, or a support ticket is a feature that will not get used.
// architecture
How it fits together
It is a single Next.js App Router application — one deployable, no separate backend service.
That was a deliberate call. A separate API server would have bought clean separation and cost
me a second deploy target, a second set of secrets, and CORS on every call, for a project where
one person is on call. Instead, authorization lives in two tiers: src/proxy.ts runs at the
edge and does IP rate limiting plus a role-and-onboarding routing gate via an edge-safe
auth.config.ts (no Node imports, so it can run in the middleware runtime), while every
/api/* handler re-checks ownership against the database itself. The middleware explicitly
returns true for API paths rather than redirecting — a 302 to /login in response to a
fetch() is a terrible API contract. Persistence is Neon serverless Postgres through Drizzle,
chosen because a Lambda-per-request runtime cannot hold a connection pool, and because Drizzle's
generated SQL is inspectable when a query goes slow.
Real-time is the other place I rejected the obvious answer. Students need to see a quiz go live
and get auto-submitted when the teacher stops it. WebSockets on Amplify would have meant API
Gateway and a second piece of infrastructure; instead /api/quizzes/[quizId]/stream is a
Node-runtime SSE endpoint that polls the quiz row every 3 seconds and pushes only on change,
with a 15-second comment heartbeat to keep intermediaries from closing the pipe. It is
DB-polling wearing a push interface, and for "did this one row's status change" that is the
right amount of machinery. Scheduled quizzes follow the same philosophy: autoActivateIfScheduled
flips a PUBLISHED quiz to ACTIVE lazily on read, so there is no scheduler to operate.
Proctoring runs entirely client-side — TensorFlow.js and MediaPipe FaceMesh are dynamic imports
that do not load until camera permission is granted — and the server only ever receives
event rows, never video.
Student browser
Fullscreen quiz session, camera, key/clipboard blocking (quiz-session-client.tsx)
FaceMesh in-page
TF.js face landmarks every 1s; head-pose ratio decides gaze-away — no video leaves the device
proxy.ts (edge)
IP rate limits per path class, role gate, onboarding redirects (role → 2FA → dashboard)
API route handlers
Re-verify ownership per request, Zod-validate body, clamp client-supplied values
Attempt endpoints
/answer saves per keystroke-debounce, /timer clamps to server elapsed, /events logs violations
Submit handler
Drains in-flight saves, auto-scores MCQ, syncs totalMarks, flags, notifies student + teacher
Neon Postgres (Drizzle)
Attempts, answers, proctoring_events, subscriptions — the audit trail a dispute needs
SSE stream route
Polls quiz.status every 3s, pushes changes so a teacher stop auto-submits live sessions
// challenges
Where I got stuck
Gaze detection that failed every student in three seconds
The first version used iris landmarks (indices 468/473, available only with refineLandmarks: true) and incremented a violation counter on every 1-second tick while the ratio was out of range. Looking away once produced three violations in three seconds and ended the quiz. The fix was two-part: gate each episode behind a gazeAwayRef boolean with a recordGazeBack() that resets it, so one look-away counts once; and drop iris tracking entirely for a head-pose estimate — nose tip (kp 4) against the midpoint of the two cheeks (kp 234/454), flagging outside a 0.28–0.72 ratio. Head pose is a coarser signal than gaze, but it works without downloading the iris model, which is what made the model load usable on ordinary hardware.
The model silently never loaded on some machines
The detector load was wrapped in a bare .catch(() => {}), so on any laptop where the WebGL backend failed to initialize, the camera preview looked perfectly normal and proctoring simply did nothing — no error, no signal, a completely unproctored exam that looked proctored. I now try webgl and explicitly fall back to cpu, log the failure, and paint an "AI scanning…" state onto the overlay canvas while faceModelRef is still null, so an unloaded model is visible rather than invisible.
Last answer scored as zero
MCQ answers POST on click and Q&A answers POST on a 1500ms debounce, but submit reads answers straight from the database. A student who clicked an option and immediately hit submit raced their own save and lost the mark. I added a pendingSavesRef counter incremented before each save and decremented in finally, and doSubmit now spins on it with a 3-second deadline before calling the submit endpoint. A deadline rather than an unbounded wait — a hung request must not trap a student in a quiz that will not end.
Two independent paths could activate one subscription
Safepay reports success twice — once via webhook, once via the client returning to the verify endpoint — and both called activateSubscription. Whichever ran second could also re-mark an already-succeeded payment as FAILED. The fix is an atomic claim: UPDATE payments SET status='SUCCEEDED' WHERE id=? AND status='PENDING' RETURNING id, and only the caller that gets a row back activates the plan. The same onConflictDoNothing + re-select pattern guards the org trial row, where two concurrent first requests would otherwise both try to insert.
Amplify would not hand env vars to the Lambda
Five commits in one day chasing this. Amplify reserves the AUTH_ prefix, so AUTH_SECRET never reached the runtime — renamed to APP_SECRET. Then the SSR Lambda turned out not to receive console-set variables at all, so next.config.ts now enumerates all 18 server variables under env: {} to bake them into the bundle at build time. That is a real tradeoff I accepted knowingly: secrets are now build-time inputs and rotating one requires a redeploy. There is still a leftover from that hunt — /api/debug-env is gone from the filesystem but remains listed in PUBLIC_PATHS in auth.config.ts.
// new ground
What I hadn't used before
webauthn_challenges table with 5-minute expiry because challenges are single-use server state, a signature counter per credential for clone detection, and a passkey_tokens one-time token to bridge a successful WebAuthn assertion into a NextAuth session, since NextAuth has no native passkey credential path./checkout/pay/ path is incompatible with v3 trackers; the working flow is create a tracker, mint a Time-Based Token from /client/passport/v1/token with an x-sfpy-merchant-secret header (not a bearer token), then hand both to /embedded/. The general lesson: with a smaller gateway, the SDK source is the specification and the docs are a summary of an older version.ReadableStream response with heartbeats and req.signal abort cleanup. I now understand why X-Accel-Buffering: no matters and why every controller.enqueue needs a try/catch — the client can vanish between two ticks of your own interval.// takeaways
What I took away
- A proctoring failure that is invisible is worse than one that is loud: a silently unloaded detector produced exams that looked supervised and were not, which is the exact failure mode the feature exists to prevent.
- Any client-reported number that feeds a grade or an analytic has to be clamped server-side — the timer endpoint takes
min(client, server-computed elapsed)because "I finished in 4 seconds" is otherwise a valid API call. - Payment providers report success more than once by design, so idempotency belongs in a conditional
UPDATE … WHERE status='PENDING' RETURNING, not in application-level checks that two Lambdas will pass simultaneously. - Prompt injection in an education product arrives inside the uploaded coursework, not in the chat box — wrapping document text in
<document_content>tags with an explicit treat-as-data-only anchor was a cheaper mitigation than any input filter I could write. - Choosing a "boring" mechanism (poll-behind-SSE, lazy activation on read) removed two pieces of infrastructure I would otherwise have had to operate alone.
// the journey
How it actually went
I started this in March with the part I thought was the risk: authentication. Email plus OTP verification, Google OAuth, mandatory TOTP, passkeys, a role-selection gate. Three commits in and it was solid. Then the project sat almost untouched until June, and when I picked it back up I built the rest of the product — quizzes, attempts, analytics, notifications, admin — in a handful of very large commits. That gap is visible in the history and it is the honest shape of this project: an ambitious foundation, a pause, then a compressed sprint.
The thing I got most wrong was believing proctoring was mostly a computer vision problem. I built iris tracking because it sounded like the correct answer, shipped it, and it was actively harmful — it ended honest students' quizzes within seconds of a single glance, and on machines where the model failed to initialize it protected nothing at all while displaying a camera feed that implied it did. The version that works is dumber on purpose. Head pose instead of gaze, one violation per episode instead of per tick, and a visible loading state so a broken detector is something you can see. I would rather over-tolerate a look-away than fail a student who looked at their own keyboard.
The moment it clicked was the security audit in June. I went through the whole surface in one pass and found sixteen issues — cross-teacher S3 reads via unvalidated key prefixes, an SSE stream that never checked enrollment, OTPs in plaintext server logs, formula injection in CSV exports, a hardcoded fallback encryption key for TOTP secrets. None were exotic. All were the result of writing the happy path and moving on. Fixing them changed how I write handlers: every route now re-derives authorization from the database rather than trusting that middleware already handled it.
Deployment was its own education. I lost the better part of a day to AWS Amplify quietly
declining to pass AUTH_SECRET into the runtime because of a reserved prefix, and then to
discovering the SSR Lambda does not receive console environment variables at all. The fix —
baking every server variable into the bundle at build time — works, and I dislike it, and I have
written down exactly why in the config so the next person does not think it was carelessness.
What I would do differently: write tests. There are none in this repo, and the bugs that cost me the most — the gaze counter incrementing per tick, the submit/save race, the double subscription activation — are all cases a small integration suite would have caught in seconds instead of in production. I would also have kept the README honest as I went; it still describes Gemini via LangChain and iris-based gaze at a 10-events-per-60-seconds threshold, none of which is what the code does anymore.
// outcome
What it changed
Shipped and deployed on AWS Amplify (SSR) with Neon Postgres, with the last proctoring fixes
landing 3 July 2026. What exists in the repo: 71 API route handlers, a 23-table schema across 7
migrations with 21 explicit indexes on hot paths, four roles (student, teacher, organization,
admin) with a mandatory 2FA onboarding gate, browser-side proctoring producing durable
proctoring_events rows, AI question generation from a topic or from PDF/DOCX/PPTX/TXT uploads
via Groq (text and vision models, images extracted and passed alongside text), an admin console
with an audit log, and a live Safepay billing flow with six plans priced in PKR (GOLD from
249/month through ORG_ENTERPRISE at 13,999/month) enforced by per-plan classroom and quiz limits.
Two inconsistencies worth stating rather than hiding: APP_CONFIG.proctoring still declares a
10-violations-per-60-seconds threshold that no code reads — the session component hard-codes 3
violations — and subscription.ts creates a 15-day organization trial while the commit that
introduced it describes 30 days. Both are documentation drift, not behaviour bugs, but both are
the kind of thing that becomes a support argument.
// overview
In more detail
QuizNex is a full-stack SaaS examination platform supporting three distinct user roles — Students, Teachers, and Organizations — each with isolated dashboards, permissions, and feature sets.
The proctoring system runs entirely in the browser: TensorFlow.js BlazeFace (478 facial landmarks) detects iris gaze direction at 30fps; 10+ gaze-away events in a 60-second rolling window auto-flags and force-submits the attempt. Teachers control quiz sessions via Server-Sent Events — starting or stopping a quiz propagates to all enrolled student sessions in real time without WebSocket infrastructure. AI question generation uses Google Gemini (Flash 1.5) via LangChain structured output with Zod schema enforcement; zero post-processing needed. Auth supports email+OTP, Google OAuth, TOTP 2FA, and Passkeys (SimpleWebAuthn FIDO2).
// features
What it does
- In-browser TensorFlow.js eye tracking — 478 facial landmarks, 30fps inference, auto-flag on 10 violations/60s
- AI question generation — Google Gemini via LangChain structured output (Zod-validated, zero post-processing)
- Document-based generation — upload PDF/PPT/DOCX/TXT, generate contextual questions with multi-turn refinement
- SSE real-time quiz control — teacher starts/stops quiz; all student sessions update without WebSocket
- Multi-role auth — email OTP, Google OAuth, TOTP 2FA, Passkeys (FIDO2/WebAuthn) via SimpleWebAuthn
- Quiz lifecycle — DRAFT → PUBLISHED → ACTIVE → COMPLETED → ARCHIVED with scheduled activation
- Answer resilience — auto-saves every 30 seconds; localStorage fallback survives network drops
- Analytics — score distributions, per-question time heatmaps, student rankings, org-wide trends
- CSV (server) and PDF (client-side jsPDF, zero server cost) export for all analytics views
// stack