NoteMind
Engineering dossier  /  MERN + Google Gemini

A prototype, taken to production standards.

NoteMind is a full-stack note-taking platform: notes that are semantically searchable, answerable in natural language with cited sources, convertible into spaced-repetition flashcards, and linked to each other as a live graph. This page is about the half you don't see — the security model, the data-integrity guarantees, and a test suite built to fail loudly.

View the repository → Read the decisions
173
Tests passing
0
npm audit findings
83
API routes
10
Collections
2
Deployed services
01  /  The product

What it does, before how it's built

A single app with a genuinely wide surface — rich editing, eight AI capabilities, spaced repetition, a wikilink graph, full account self-service, and a separate admin console. All of it free, no ads, no paid tier.

NoteMind marketing landing page
Public marketing site — served from the same SPA build/
NoteMind dashboard with pinned notes, folders, tags and activity
Dashboard/dashboard
Force-directed graph of wikilinked notes
Wikilink graph — d3-force/graph

Notes

  • Tiptap editor — slash commands, task lists, code blocks, image paste-upload
  • [[wikilinks]] with backlinks computed both directions
  • Version history — every save snapshots; restores are themselves undoable
  • Public sharing — read-only link, no account, revoked instantly

AI — Gemini

  • Cross-note Q&A — retrieval, streamed answer, cited sources
  • Semantic search — 768-dim embeddings, cosine similarity, keyword fallback
  • Auto-tagging, titles, writing assist — strict-JSON prompting with retry
  • Weekly digest and daily note resurfacing

Knowledge tools

  • Spaced repetition — SM-2 scheduling, due-today queue, streaks
  • Flashcard generation from any note
  • Graph view — d3-force layout of linked notes only
  • Tag & folder filtering — organize without a second system

Accounts

  • Email/password and Google Sign-In, link/unlink either way
  • Password reset with hashed, single-use, expiring tokens
  • Data export — JSON and Markdown ZIP, a real backup
  • Account deletion with full cascade

Admin

  • Live stats over WebSockets — re-authorised per connection
  • 30-day growth charts, per-user content inspection
  • Moderation, bulk actions, broadcast notifications
  • Append-only audit log with plain-text target snapshots

Platform

  • Installable PWA — offline-read service worker, per-user cache
  • Structured JSON logging with secret & note-body redaction
  • Scheduled jobs — reminders, weekly digest
  • Graceful shutdown draining in-flight streams
02  /  Architecture

Two independent projects, deployed separately

A React SPA on static hosting; a long-running Express + Socket.IO API on a container platform. No workspace tooling — each side installs, tests, and ships on its own.

NoteMind — System Architecture React 19 SPA · Express 5 API · MongoDB Atlas · Socket.IO · Google Gemini — two independently deployed npm projects Browser · Vercel (static) React 19 SPA Vite build · Tailwind 4 Tiptap editor · d3-force graph AuthContext → localStorage token Transport Axios instance (Bearer interceptor) fetch + streams for AI & export Service worker (PWA) cache-first assets · network-first shell SWR note reads, partitioned per user Public marketing site landing, features, use-cases, changelog served from the same SPA build Shared public note view no account · read-only signed per-image URLs API · Render (long-running Docker) http.createServer(app) Express 5 + Socket.IO share one port · helmet / CORS allowlist / CSP Middleware pipeline protect (JWT) requireAdmin (DB role) validateBody (Zod) rateLimit (Mongo) aiQuota (per user/day) errorHandler Controllers fetch-then-check ownership · throw HttpError(status, msg) Services aiService — strict-JSON prompts, retry, streaming geminiKeyPool — round-robin, quota-aware cooldowns dataCleanup — one cascade path, zero orphans imageSignature · email · noteExport · resurface scheduler — reminders (5 min), weekly digest socket — admin room, re-auth per connection Models — Mongoose (10 collections) User · Note · NoteVersion · Flashcard · AiUsage Notification · Resurface · Template PasswordResetToken · AdminAuditLog Data & external services MongoDB Atlas primary datastore + rate-limit & job state in-memory Mongo in tests Cloudflare R2 note image object storage local disk fallback if unset Google Gemini gemini-flash-latest 768-dim embeddings multi-key pool, per-key cooldown Email Gmail SMTP or Resend half-configured → refuses boot Observability pino structured JSON logs secret / note-body redaction Sentry (optional) · /healthz HTTPS REST WSS · Socket.IO unauth /api/public Mongoose HTTPS / S3 / SMTP AI path data store / unauthenticated application layer
Figure 1 — request path, middleware pipeline, and external service boundaries

Layering

routes → middleware → controllers → services → models. Controllers throw HttpError(status, message); Express 5 forwards rejected async handlers to one error handler that maps known types to 4xx and everything else to a bare 500 — internals never leak.

Why the API isn't serverless

Socket.IO rooms span every connection. On serverless, a socket held by one instance never receives an event emitted from another — the badge would read “Live” while nothing updated. Consolidating needs a Redis pub/sub backplane first.

Retrieval, then generation

Q&A and search prefer per-note embedding similarity and fall back to keyword overlap when an embedding is missing — so a note written before embeddings existed, or during a Gemini outage, still participates rather than disappearing from results.

The Gemini key pool

Any number of keys, round-robined across those not in cooldown. A PerDay quota violation cools that key until the next Pacific midnight (DST-safe via Intl); anything else takes a short backoff. Google's own retryDelay hint is not trusted for daily quotas.

03  /  Decisions

The decisions a reader is most likely to question

Six of them, each with the failure it prevents. Several were found by reintroducing a bug that had already shipped once.

S·01
S·01sessions

Sessions are verified, not merely decoded

A JWT signature proves a token was issued; it doesn't prove the account still exists or is still permitted. The auth middleware loads the user on every authenticated request, so deletion and suspension take effect immediately rather than up to seven days later at token expiry. It costs nothing extra — the middleware already writes lastActiveAt per request.

passwordChangedAt extends this into real revocation: a reset invalidates every token issued before it — which matters, because a reset is usually performed because an account is believed compromised.

S·02
S·02data integrity

One cleanup path, not six

Deleting a note or a user touches versions, flashcards, resurfacing history, notification references, wikilink back-references, and object storage. Six call sites each implementing that cascade independently is six chances to forget one.

Observed symptomflashcards that kept quizzing users on notes they had permanently deleted.

All six now funnel through services/dataCleanup.js, and the test suite asserts zero orphans per path.

S·03
S·03asset auth

Signed asset URLs, per file

<img> can't send an Authorization header, and this app authenticates with bearer tokens, not cookies — so putting /uploads behind auth middleware would break every image in every note.

Instead, a protected endpoint verifies ownership and returns per-file HMAC signatures with a one-hour expiry; the public route only verifies. Signing each file individually keeps a leaked URL worth exactly one image — as it was before — while adding an expiry it never had. Shared public notes reuse the same primitive.

S·04
S·04correctness

Streak math survives Daylight Saving Time

Stepping backward through days with fixed 86,400,000ms arithmetic breaks twice a year: a spring-forward day is 23 hours of real time, so subtracting a full day skips a calendar day; a fall-back day is 25 hours and double-counts one.

Every streak calculation now steps using the Date object's own local-time setters, which the engine re-normalises across the transition. Proven with a test that pins the system clock to a real DST boundary and fails outright against the old implementation.

S·05
S·05cost control

Metered AI needs per-user accounting

An IP-based rate limiter can't answer “who ran up this bill”, counts an office behind one NAT as a single user, and resets on deploy. AiUsage tracks calls per user per UTC day via an atomic upsert, so concurrent requests can't both slip through a stale read.

Note saves consume that quota softly: every save triggers a billed embedding call, but an exhausted quota degrades the note to keyword matching rather than blocking the save. “You've used your AI quota, so you can no longer write notes” is not a defensible product behaviour.

S·06
S·06operability

Fail fast on configuration

A mistyped JWT_SECRET used to bind the port, pass the health check, and then return 500 on every login — a total outage that looked like a healthy deploy. config/env.js validates everything at boot and exits with a readable message.

Paired variables are checked together, because half a feature's configuration is worse than none: a partially configured email backend silently falls back to logging, indistinguishable from working until a real user needs a password reset.

04  /  Testing

A suite built to fail loudly

Some bugs no unit test would ever surface — like focus being stolen between two chained modals because React commits every effect cleanup before running any new effect. That one was found by clicking through the flow and checking document.activeElement. For everything else, there's Vitest.

A test that still passes with the defect present is worse than no test — it manufactures confidence.

The suite was validated by reintroducing previously fixed bugs and confirming each one was caught. It runs against an in-memory MongoDB — no external services, no fixtures to reset, no shared state between runs. Coverage is targeted at the invariants the security model rests on, chosen so that breaking one fails loudly.

SuiteAsserts
authorizationEvery note route 403s a foreign note; admin gating; role changes apply to already-issued tokens
sessionDeleted, suspended, malformed and expired sessions rejected; bad input is 4xx, never 500
dataCleanupEach delete path leaves zero orphans; streaks survive; backlinks are pruned
securityXSS vectors stripped; prototype-chain filter bypass closed; archive/trash exclusivity
imageSignatureSignatures bound to filename and expiry; unsigned and tampered requests refused
passwordResetTokens hashed, single-use, expiring; no account-existence oracle; prior sessions revoked
googleLinkIdentity linking rules, including the account pre-hijacking case
accountPassword / Google account state; deletion cascade
emailProvider selection; half-configured backends refuse to boot
adminGuardsThe last remaining admin can never be demoted or deleted through the admin surface
streaksNote, flashcard and resurfacing streaks stay correct across a real DST boundary
shareNoteFull share lifecycle; the public payload never leaks owner identity
schedulerReminder and digest jobs fire on schedule, dedupe, and no-op cleanly with no email provider
templatesBuilt-in and user-authored templates round-trip through HTML sanitisation intact
server  $ npm test   →  173 passed (14 files)
client  $ npm run lint && npm run build   →  clean
ci     lint + build · full server suite · Docker image build — every push & PR
05  /  Stack

What it's made of

Frontend

React 19 · Vite · Tailwind CSS 4 · Tiptap · d3-force · cmdk · @react-oauth/google

Backend

Node ≥ 20 · Express 5 · Mongoose · Socket.IO · Zod · pino · bcrypt · custom Mongo rate-limit store

AI

Google Gemini (gemini-flash-latest) · 768-dim embeddings · multi-key pool with quota-aware cooldowns

Data & storage

MongoDB Atlas · Cloudflare R2 (S3 API) with local-disk fallback

Testing

Vitest · Supertest · mongodb-memory-server — 173 tests, no external dependencies

Delivery

Vercel (client) · Render (Docker API) · GitHub Actions CI · Gmail SMTP or Resend