An online coding-judge platform (LeetCode-style) β problems, contests with live leaderboards, DSA sheets, community solutions/discussions β with server-side verdicting and an isolated code-execution pipeline. Live: https://codearena.kodexa.in
This document does two jobs: it is the project README, and it is a line-by-line defence of the three resume bullets, written so that every claim maps to a file you can open and explain. Sections marked π€ are interview Q&A.
- What the product does
- Tech stack
- Architecture
- Resume bullet 1 β secure judge & no test-case leakage
- Resume bullet 2 β isolated execution pipeline (Judge0-compatible adapter + Codebox)
- Resume bullet 3 β production deployment (Lightsail, Compose, Caddy, Cloudflare, networking)
- Auth & security model
- Data model
- Contests & real-time leaderboard
- Payments (Razorpay) β webhook correctness
- Operational stuff β health, logging, rate limits, deploy
- Honest gaps & what I'd do next (be ready for these)
- Rapid-fire question bank
- Running locally
| Area | Features |
|---|---|
| Problems | Markdown statements, examples, constraints, hints, editorial, per-language starter snippets (Python, Java, JS, C++, C), Run (custom stdin) vs Submit (hidden testcases) |
| Submissions | Per-testcase pass/fail, time & memory, history per user |
| Contests | Timed, join/leave, ICPC-style scoring (100 pts on first AC + penalty minutes), live leaderboard via Socket.IO |
| DSA Sheets | Curated day-by-day structured plans with per-user progress |
| Playlists | User-owned problem lists |
| Community | Solutions, discussions, nested comments, votes, follows, reports, public profiles, global leaderboard with points (EASY 10 / MEDIUM 20 / HARD 30, awarded once per problem) |
| Auth | Email/password + GitHub/Google OAuth, email verification (nodemailer), password reset, single-admin role model |
| Admin | Problem/contest/sheet CRUD, user management, "live now" presence via heartbeat, content moderation |
| Support | Pay-what-you-want donations via Razorpay with webhook reconciliation |
| Layer | Choice | Where |
|---|---|---|
| Frontend | React 19 + Vite 7, React Router 7, Zustand, react-hook-form + zod, Monaco editor, Tailwind 4, framer-motion, socket.io-client, react-markdown | frontend/ |
| Backend | Node 20, Express 4 (ESM), Prisma 6, jsonwebtoken, bcryptjs, helmet, express-rate-limit, pino, socket.io, nodemailer, razorpay | backend/src/ |
| DB | PostgreSQL 16 (Prisma migrations) | backend/prisma/ |
| Cache | Redis 7 (provisioned β see Β§12) | docker-compose.prod.yml |
| Executor | Codebox (self-hosted, Judge0-CE-compatible API, isolate sandbox) |
separate compose on the same host |
| Edge | Caddy 2 (auto-TLS) β nginx (SPA) / Express (API); optional Cloudflare proxy | Caddyfile, frontend/nginx.conf |
| Infra | AWS Lightsail (ap-south-1), Docker Compose, manual git pull deploy |
backend/docs/DEPLOY.md |
Browser ββHTTPSβββΆ [Cloudflare (optional, CDN/DDoS)] βββΆ Caddy :443 (TLS, gzip/zstd, security headers)
β
ββββββββββββββββββββββββββΌβββββββββββββββββββββββββββ
β /api/* /socket.io/* β everything elseβ
βΌ β βΌ
backend :8080 (Express+Socket.IO) β frontend :80 (nginx, static SPA)
β β
βββββββββββββββΌβββββββββββββββ β docker network: `codearena` (bridge)
βΌ βΌ βΌ β
Postgres 16 Redis 7 host.docker.internal:3000 βββΆ Codebox API (bound to 172.17.0.1 only)
β β X-Auth-Token
β βΌ
β Codebox Redis (BullMQ queue)
β β
β βΌ
β Codebox worker (privileged, `isolate` sandbox per run)
β docker network: codebox's own compose network
Request flow for a Submit:
- SPA
POST /api/v1/execute-code{ source_code, language_id, problemId }(httpOnly JWT cookie rides along). - Caddy routes
/api/*β backend.executeLimiter(30/min/IP) +authMiddleware+requireVerified. - Backend loads the problem's full hidden testcases from Postgres (
executeCode.controllers.js). - Builds N executor submissions (one per testcase),
submitBatchβ Codebox (chunked β€20), gets tokens. pollBatchResultspollsGET /submissions/batch?tokens=β¦every 2 s until no result is inqueued/processing(status id 1/2), β€180 s.- Compares
stdout.trim() === expected.trim()per testcase; writesSubmission+TestCaseResult[]; awards points on first AC. - Returns only
{ status, passed, total, results:[{testCase, passed, status, time, memory}] }β no stdin, no expected output, no stdout.
Built a secure online coding-judge platform (React, Node.js/Express, PostgreSQL, Redis) with server-side verdicting that prevents hidden test-case leakage by returning only minimal evaluation metadata to clients.
The client never computes or sees a verdict input. The only thing the browser sends is source + language + problemId; the only thing it gets back is pass/fail metadata. All comparison happens in backend/src/controllers/executeCode.controllers.js.
| Layer | File | Mechanism |
|---|---|---|
| Read API | problem.controllers.js β PUBLIC_DETAIL_SELECT / PUBLIC_LIST_SELECT |
Prisma select whitelists that omit testcases and referenceSolutions. Non-admins can never fetch them β even GET /problems/:id uses the whitelist unless req.user.role === "ADMIN". An allow-list (select) is safer than a deny-list (omit) because a new sensitive column is hidden by default. |
| Submit response | executeCode.controllers.js final res.json |
results.map(r => ({ testCase, passed, status, time, memory })) β strips stdout, expected, stdin, stderr. You learn that test 7 failed, not what test 7 was. |
| Run endpoint | runCode |
Executes only the user-supplied stdin, no DB read of testcases, no DB write. So "Run" can't be used as an oracle on hidden cases. |
Q: Why not send testcases to the client and let it compare? Much simpler. Because then the client is the trust boundary. A user could read the expected outputs from the network tab, or forge an "Accepted" POST. Verdicts drive points, leaderboards, contest rankings β all must be produced by code the user can't modify.
Q: Couldn't someone still leak a testcase by printing the input?
The submit response doesn't include stdout, so printing stdin gets them nothing. stderr/compile_output are also stripped. The full detail is stored in TestCaseResult for admin debugging only. (Original version returned stdout/expected β that was an actual bug I fixed; the old getAllProblem also returned testcases to everyone. See backend/docs/architecture.md Β§2.3 "Answer leak".)
Q: What about timing side-channels / testcase count? Testcase count and per-case timing are exposed. That's an accepted trade-off (LeetCode does the same β users want to know "3/40 passed"). If it mattered, I'd return only aggregate pass counts.
Q: Is the comparison robust?
It's deterministic stdout equality after trim(). Deliberately strict: no floating-point tolerance, no whitespace normalisation inside lines, no special judges. Problem authors must write outputs with a single canonical form. (Extension: a checker field per problem for token-based or epsilon comparison.)
Q: How do you guarantee the expected outputs are even correct?
validateReferenceSolutions in problem.controllers.js β on create/update of a published problem, every reference solution in every language is run through the executor against every testcase; the save is rejected on the first mismatch. So a problem can't go live with an answer key that the reference solution itself fails.
Q: Where is Redis used?
Honestly: Redis is provisioned in Compose (and Codebox uses its own Redis for BullMQ), but the app backend does not currently have Redis calls β rate limiting is in-memory and refresh tokens live in a Postgres column. The designed next step (documented in architecture.md) is Redis for the rate-limit store, refresh-token/session store, hot-read cache (problem list, leaderboard), and the Socket.IO adapter for multi-process. Say this plainly if asked β it's better than being caught.
Engineered an isolated code-execution pipeline via a Judge0-compatible adapter and self-hosted Codebox sandbox, routing execution through authenticated internal service calls and deterministic stdout-based validation for untrusted submissions.
The app originally used hosted Judge0 (via Sulu's API). I swapped to self-hosted Codebox, which speaks the Judge0-CE wire protocol: same numeric language IDs (71 Python, 62 Java, 63 JS, 54 C++, 50 C), same POST /submissions/batch β tokens β GET /submissions/batch?tokens= model, same status.id semantics (1 = In Queue, 2 = Processing, 3 = Accepted, β¦).
Because the rest of the app only talks to submitBatch / pollBatchResults / getJudge0LanguageId, switching engines touched exactly one file. Differences absorbed by the adapter:
| Concern | Judge0/Sulu | Codebox | Handled by |
|---|---|---|---|
| Base URL | public SaaS | http://host.docker.internal:3000 |
CODEBOX_API_URL env |
| Auth header | Authorization: Bearer |
X-Auth-Token |
authHeaders |
| Batch size | unbounded | β€20/request | chunk(arr, 20) on submit and poll |
| Response shape | { submissions: [...] } |
bare array on submit, {submissions} on poll |
adapter normalises |
| Rate limiting | none | 429s on fast polling | 2 s cadence, 429 β sleep & retry instead of failing |
| Long runs | β | C++/Java compile per testcase, serial worker | poll budget 90 Γ 2 s β 180 s (was 30 Γ 1 s β caused 500s on 40+ testcase problems; see commit c317f1c) |
- Cost & quotas: hosted Judge0 tiers cap requests/day; a single HARD problem with 75 testcases = 75 executions per submit.
- Data control: user code never leaves my box.
- Latency: same-host call instead of a cross-region round trip per poll.
- Trade-off: I own the security of the sandbox now.
- Codebox = API (Express) + Redis (BullMQ queue) + worker. Worker runs each submission inside
isolate(the IOI/Codeforces sandbox): Linux namespaces + cgroup v2 limits on CPU time, wall time, memory, process count, and a read-only rootfs with no network. - Worker container is
privileged(required by isolate for cgroups/namespaces);WORKER_CONCURRENCY=1on a 2-vCPU box. - Gotcha I hit and documented: Codebox's
dockerexecutor mode breaks on Docker β₯ 29, soisolateis mandatory; isolate also now needslibseccomp-devat build time (I patch the Dockerfile in the runbook).
- Codebox's API port is published only on the Docker gateway IP
172.17.0.1:3000β not0.0.0.0, so it's unreachable from the internet and from the Lightsail firewall's perspective doesn't exist. - The backend reaches it through
host.docker.internal(mapped viaextra_hosts: host-gatewayin Compose). - Every call carries
X-Auth-Token: $CODEBOX_AUTH_TOKEN, a shared secret set in both.envfiles; Codebox rejects requests without it. So even a container on the host that found the port couldn't submit code.
Untrusted code is treated as a black box: stdin in β stdout out. No function-signature harness, no injecting the user's code into a driver file per language. Advantages: language-agnostic, nothing for the user to "return", impossible to tamper with the checker because there is no checker in their process. Cost: problems must be phrased as stdin/stdout (we generate starter codeSnippets that read stdin).
Q: Walk me through what happens when I click Submit, end to end. β Β§3 request flow. Know the 7 steps cold.
Q: Why polling instead of webhooks/callbacks from the executor?
Judge0 supports callback_url, but polling keeps the executor unable to initiate connections into the app (simpler trust model, no public callback endpoint to secure), and for a same-host call the poll cost is trivial. Next step would be a job queue (BullMQ in the app) with the HTTP request returning a submissionId immediately and the client subscribing via Socket.IO β this also fixes the "request held open for 3 minutes" problem.
Q: What stops a user from fork-bombing, reading /etc/passwd, or making network calls?
isolate: pid limits (--processes), memory/cpu/wall limits, own mount namespace with a minimal rootfs, no network namespace. Their code can't see the host, other submissions, or the Codebox Redis.
Q: What if Codebox is down?
submitBatch throws β controller returns 500 "Error running your code"; nothing is written to DB. The /api/v1/health endpoint checks DB only; adding a Codebox /health check is an obvious TODO.
Q: How would you scale execution?
Executor is the real bottleneck, not web traffic. Options in order: raise WORKER_CONCURRENCY with more cores β run Codebox workers on separate boxes pointed at one Redis queue β per-user submit quotas & a bounded queue so a "Run" stampede degrades gracefully instead of OOMing the host. Also compile once per submission and run N times (Judge0 can't; a custom worker could) β this alone would make C++/Java ~NΓ cheaper.
Q: How did you debug the 500s on many-testcase submissions?
Symptom: Accepted on EASY, 500 on HARD C++. pino logs showed Execution timeout - results not available from the poll loop after 30 s, while Codebox was still happily processing. Root cause: poll budget assumed Judge0-style parallel execution; Codebox compiled each of 75 testcases serially. Fix: widen budget, slow cadence (which also stopped tripping Codebox's rate limiter), treat 429 as retryable.
Deployed a production container stack on an AWS Lightsail single VPS using Docker Compose, with Caddy reverse proxy, optional Cloudflare edge proxy and segmented Docker networking for secure service-to-service communication.
| Container | Image | Exposed ports | Role |
|---|---|---|---|
codearena-caddy |
caddy:2-alpine |
80, 443 (the only public ports) | TLS termination, routing, compression, security headers |
codearena-frontend |
custom (node build β nginx:alpine) |
none | Serves the Vite build; /health for Docker healthcheck |
codearena-backend |
custom (node:20) |
none | Express API + Socket.IO on 8080 |
codearena-postgres |
postgres:16-alpine |
none | pg_isready healthcheck; backend waits on service_healthy |
codearena-redis |
redis:7-alpine |
none | AOF persistence |
codebox-{api,worker,redis} |
separate compose project | api on 172.17.0.1:3000 only |
Execution engine |
codearena.kodexa.in { β¦ }β one site block; Caddy auto-provisions and renews a Let's Encrypt cert (HTTP-01) when the DNS record is grey-cloud.@api path /api/* /socket.io/*βreverse_proxy backend:8080(Caddy handles WebSocket upgrade transparently). Everything else βfrontend:80.encode zstd gzip; headersX-Frame-Options SAMEORIGIN,X-Content-Type-Options nosniff,Referrer-Policy, and-Serverto hide the server banner.- Same-origin SPA + API β no CORS in prod for browser traffic, cookies are first-party,
sameSite: laxworks.
Why Caddy over nginx at the edge? Zero-config automatic HTTPS with renewal, a 20-line config vs ~100 for the nginx equivalent with certbot, native WebSocket proxying. nginx is still used inside the frontend container purely as a static file server.
- Orange-cloud the A record β Cloudflare terminates TLS at the edge, caches static assets, absorbs L3/L4/L7 DDoS, hides the origin IP.
- Origin side: set SSL mode Full (Strict) and give Caddy a Cloudflare Origin Certificate (
tls /etc/caddy/origin.pem /etc/caddy/origin.key) β or use the DNS-01 challenge with thecaddy-dns/cloudflareplugin, because HTTP-01 won't complete through the proxy. - Backend sets
app.set("trust proxy", 1)soexpress-rate-limitsees the real client IP fromX-Forwarded-Forinstead of rate-limiting Caddy itself, andsecurecookies are honoured.
- App network (
codearena, bridge): caddy, frontend, backend, postgres, redis. Services resolve each other by name (postgres:5432,backend:8080). Only Caddy publishes host ports; Postgres/Redis have noports:at all, so they are unreachable even from the host's loopback. - Executor network (Codebox's own compose project): api, worker, redis β a separate bridge. The app cannot reach Codebox's Redis or worker; it can reach only the API, only via the gateway IP, only with the auth token.
- The two projects are connected through a single deliberate seam:
ports: ["172.17.0.1:3000:3000"]on Codebox +extra_hosts: host.docker.internal:host-gatewayon the backend. I chose the docker-gateway IP rather than127.0.0.1because a port bound to the host's loopback is not reachable from inside a bridge-network container viahost.docker.internalon Linux. - Lightsail firewall: 22, 80, 443 only.
ssh ubuntu@<ip>
cd /home/ubuntu/CodeArena && git pull --ff-only origin main
docker compose -f docker-compose.prod.yml build
docker compose -f docker-compose.prod.yml run --rm -T backend npx prisma migrate deploy
docker compose -f docker-compose.prod.yml up -d
curl -s https://codearena.kodexa.in/api/v1/health # {"success":true,"status":"ok","db":true}Secrets live in an uncommitted .env on the box and are injected via Compose ${VAR} interpolation. SSH uses short-lived keys minted with aws lightsail get-instance-access-details.
Q: Single VPS β isn't that a SPOF? Yes, and that's the right call for this stage: one box, ~$10β20/month, Compose restart policies (unless-stopped) + Docker healthchecks for self-healing. Scaling path is documented in architecture.md: PM2 cluster for the stateless API, PgBouncer in front of Postgres, Redis adapter for Socket.IO, executor workers on separate hosts, then managed Postgres. Multi-AZ/k8s would be premature.
Q: How do you do zero-downtime deploys? I don't β up -d recreates changed containers with a few seconds of 502 from Caddy. For zero-downtime I'd run two backend replicas behind Caddy's reverse_proxy with health-checked lb_policy and --scale one at a time, or move to a blue/green pair of compose projects.
Q: Backups? Postgres data is a named volume; a cron'd pg_dump to S3 is the intended next step (MONITORING.md). Be honest if it's not set up yet.
Q: Why Lightsail and not EC2/ECS? Fixed predictable pricing, bundled static IP and firewall, no VPC/IAM yak-shaving. Workload is one box anyway.
Q: How are secrets handled? .env on the box (chmod 600, never committed), referenced via Compose interpolation, SECRET/REFRESH_SECRET/CODEBOX_AUTH_TOKEN generated with openssl rand -hex 32. Next step: AWS SSM Parameter Store or Docker secrets.
Q: What does depends_on: condition: service_healthy buy you? Backend doesn't boot until pg_isready and redis-cli ping pass, so Prisma doesn't crash-loop on a cold start.
Q: Why does the nginx healthcheck use 127.0.0.1 not localhost? Alpine resolves localhost to ::1 first and nginx only listens on IPv4 β false "unhealthy". Small but it's the kind of detail that shows you actually ran it.
| Topic | Implementation |
|---|---|
| Password hashing | bcrypt, cost 12 |
| Tokens | Two JWTs: access (15m, SECRET) + refresh (7d, or 30d with "remember me", REFRESH_SECRET), both in httpOnly, secure, sameSite=lax cookies |
| Refresh rotation | Refresh token stored on User.refreshToken; /auth/refresh-token verifies signature and equality with the stored value, then issues a new pair (rotation). Logout / password change null it β revocation. |
| Frontend | Axios interceptor: on 401, call refresh once (a shared refreshPromise coalesces concurrent 401s), replay the original request; excludes login/register/refresh from the retry to prevent loops |
| OAuth | GitHub + Google; OAuthAccount table with @@unique([provider, providerId]); OAuth users auto-verified |
| Email verification | requireVerified middleware gates writes (submit, post, vote); reads stay open |
| RBAC | USER | ADMIN single-admin model (ADMIN_EMAIL bootstraps). checkAdmin, checkOwnership(model, param, ownerField) for per-row authorisation |
| Socket.IO auth | JWT passed in handshake.auth.token, verified in io.use() middleware; rooms contest_<id> |
| HTTP hardening | helmet (CSP off at API; CSP belongs on the SPA edge), CORS locked to FRONTEND_ORIGIN with credentials, body limit 1 MB, global 404 + error handler that hides stacks outside development |
| Rate limits | /api 300/min; /auth 40/15 min (brute-force); /execute-code 30/min; heartbeat mounted before the auth limiter so a 45 s ping doesn't exhaust the login budget |
| Webhooks | Raw body + HMAC-SHA256 + crypto.timingSafeEqual (Β§10) |
π€ Why cookies and not Authorization: Bearer from localStorage? httpOnly cookies are immune to XSS token theft; sameSite=lax + CORS origin lock covers CSRF for JSON APIs (a cross-site form POST can't set Content-Type: application/json, and lax blocks cross-site POST cookies anyway). lax rather than strict so the cookie survives the OAuth provider β app top-level redirect. The middleware also accepts a Bearer header for the Socket.IO handshake and tooling.
π€ Why 15-minute access tokens? Limits the blast radius of a leaked token while the refresh rotation gives long sessions. Tokens aren't stateless-revocable, so short expiry + stored refresh token is the compromise.
Prisma schema highlights (backend/prisma/schema.prisma, 4 migrations):
Problemβtestcases Json,referenceSolutions Json,codeSnippets Json,examples Json;@@index([published, difficulty]). JSON for testcases because they're always read as a whole and never queried individually; aTestCasetable would be 75 rows Γ N problems for no benefit.Submission+TestCaseResult(1:N) β per-case detail kept for admin/debug even though the client gets a summary.ProblemSolved@@unique([userId, problemId])β the DB-level guard that points are awarded once even under concurrent submits.Contest/ContestParticipant/ContestSubmission/ContestLeaderboardβ@@index([contestId, totalScore, penalty])matches the leaderboardORDER BY.- Community:
Solution,Discussion,Comment(self-relationparentIdfor threads),Vote@@unique([userId, targetType, targetId])(polymorphic, one vote per user per target),Follow,Report. Donation+WebhookEvent(idempotency ledger keyed onx-razorpay-event-id).Userβpoints,lastSeenAt/lastLoginAt(indexed, drives admin "live now"),refreshToken,emailVerified, reset-token fields.
π€ Points race condition? findUnique then create isn't atomic, but the @@unique([userId, problemId]) constraint makes the second concurrent create throw, so double-award can't persist. Cleaner: wrap in db.$transaction or use INSERT β¦ ON CONFLICT DO NOTHING RETURNING and only increment when a row was inserted.
- Scoring in
contest.controllers.js: first AC on a problem = 100 points + penalty = minutes since contest start; subsequent ACs don't re-score. LeaderboardORDER BY totalScore DESC, penalty ASC, lastSubmission ASC. - After every accepted contest submission:
updateLeaderboard(Prisma upsert withincrement) thenio.to("contest_<id>").emit("leaderboardUpdate", fullBoard). - Clients
joinConteston mount,leaveConteston unmount; Socket.IO handshake is JWT-authenticated.
π€ Why push the whole leaderboard rather than a delta? Boards are β€ a few hundred rows; simplicity wins, and it's self-healing (a missed event doesn't leave a client stale). Scaling: the Socket.IO Redis adapter so multiple API processes share rooms; throttle emits to at most once per second per contest.
donationWebhook.controllers.js, mounted before express.json() with express.raw():
- Recompute
HMAC-SHA256(raw body, RAZORPAY_WEBHOOK_SECRET)and compare tox-razorpay-signaturewithcrypto.timingSafeEqual(constant-time; lengths checked first). - Insert
WebhookEventkeyed onx-razorpay-event-idβ a duplicate delivery is a no-op. - Apply a guarded state transition on
Donation(created β paidonly; never regress). - ACK 200 quickly; a background reconciler (
libs/reconcile.js, started inindex.js) periodically re-queries Razorpay for stuck orders because client-side success callbacks are unreliable.
π€ Why raw body? HMAC is over the exact bytes; express.json() re-serialisation changes whitespace/key order and the signature would never match. Why timing-safe compare? === short-circuits on first mismatch β timing oracle on the signature.
- Health:
GET /api/v1/healthβ{status, uptime, db:true|false}(runsSELECT 1); nginx/healthfor the SPA container; Docker healthchecks on postgres/redis/frontend. - Logging:
pino-httpstructured JSON request logs;pino-prettyin dev.docker compose logs -f backendin prod. - Monitoring: see
backend/docs/MONITORING.md(uptime ping on/health). - Migrations:
prisma migrate deployrun as a one-off container after building the new image and beforeup -d. Migrations are additive so old containers tolerate the new schema during the swap. - Seeding:
prisma/seed.jscreates the admin (ADMIN_EMAIL) + starter problems;scripts/backfill-points.jsrecomputed points when the scoring rule changed.
Interviewers respect candour. Lead with these before they find them.
| Gap | Status | Fix |
|---|---|---|
| Redis unused by app code | Provisioned, idle | Rate-limit store (rate-limit-redis), refresh-token store, problem-list/leaderboard cache, Socket.IO adapter |
| Submit holds the HTTP request open for up to 3 min | Works, but ties a Node event-loop slot & risks proxy timeouts | Enqueue β return submissionId β push verdict over Socket.IO |
| Compile once per testcase (C++/Java) | Slow on 40β75-case problems | Custom worker step: compile once, run N times |
| No tests | backend/test.js is a scratch file |
Supertest integration tests for auth + execute + problem select-whitelist (a regression test that testcases never appears in a non-admin response is the single most valuable one) |
| No CI/CD | Manual git pull |
GitHub Actions β build images β SSH deploy; or Watchtower |
| No DB backups | Named volume only | Nightly pg_dump β S3 lifecycle |
trust proxy: 1 |
Correct for Caddy alone; with Cloudflare in front there are 2 hops | Set to 2 or trust Cloudflare IP ranges, else rate-limit keys on Cloudflare's IP |
| Points award not transactional | Guarded by unique constraint | $transaction / ON CONFLICT |
| AI code review route | Stub | Anthropic API call with the problem + submission as context |
| Output comparison is exact-match only | By design | Optional per-problem checker (token/epsilon/special judge) |
Design / trade-offs
- Why Express over Nest/Fastify? β Familiarity, huge middleware ecosystem (helmet, rate-limit, pino-http), app is I/O-bound so framework overhead is irrelevant; the bottleneck is the executor.
- Why Prisma? β Type-safe queries, migrations as code,
selectwhitelists make the no-leak guarantee explicit and reviewable. - Why Postgres JSON for testcases? β Read-whole, write-whole, never filtered; avoids a join on every submit.
- Why Socket.IO not SSE? β Need rooms, reconnection, auth middleware; SSE would suffice for one-way leaderboard but Socket.IO was already needed for contests.
- Why Vite/React SPA + nginx rather than SSR? β App is behind login; no SEO need; static hosting is cheapest and cacheable at Cloudflare.
- Why Zustand? β Tiny, no boilerplate; only auth/user state is truly global.
Security
- Threat model for the executor? β Malicious code: resource exhaustion, filesystem/network escape, cross-submission snooping, host compromise. Mitigations: isolate limits, no network, private port + shared token, privileged container is the worker not the API.
- What's in the JWT? β
{ id, type: "access"|"refresh" }only; user fields are re-read from DB on every request so deactivation (isActive=false) takes effect immediately. - How do you stop a user editing someone else's playlist? β
checkOwnership("playlist", "id", "userId")β admin bypass, else row's owner must equalreq.user.id. - SQL injection? β Prisma parameterises everything; the one
$queryRawis a constantSELECT 1. - XSS in markdown statements/solutions? β
react-markdowndoesn't render raw HTML by default; norehype-raw.
Ops
- How do you roll back? β
git checkout <prev-sha>+ rebuild +up -d; migrations are additive so no down-migration needed in practice. - What happens on box reboot? β All services
restart: unless-stopped; Caddy re-uses its cached cert from thecaddy_datavolume. - How do you know it's down? β Uptime monitor on
/api/v1/health;db:falsein the payload distinguishes app-up/DB-down.
Behavioural hooks (have a story ready)
- Hardest bug β the poll-timeout 500s (Β§5).
- A decision you reversed β hosted Judge0 β self-hosted Codebox; also SUPERADMIN multi-role β single admin (simplicity).
- Something you'd do differently β queue-based async submit from day one.
# Backend
cd backend && npm ci
cp .env.example .env # DATABASE_URL, SECRET, REFRESH_SECRET, CODEBOX_API_URL, CODEBOX_AUTH_TOKEN β¦
npx prisma migrate dev && node prisma/seed.js
npm run dev # :8080
# Frontend
cd frontend && npm ci && npm run dev # :3000, Vite proxies /api β :8080
# Or everything via compose (dev profile, hot reload)
docker compose -f docker-compose.dev.yml up --buildExecutor for local dev: run Codebox (docker compose up in its repo) and point CODEBOX_API_URL=http://localhost:3000 with a matching AUTH_TOKEN.
Further reading in-repo: backend/docs/architecture.md (target design & scaling), backend/docs/DEPLOY.md (prod runbook), backend/docs/DATABASE.md, backend/docs/PAYMENTS.md, backend/docs/MONITORING.md.