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.
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.
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.
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.
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.
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.
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.
Six of them, each with the failure it prevents. Several were found by reintroducing a bug that had already shipped once.
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.
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.
<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.
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.
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.
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.
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.
| Suite | Asserts |
|---|---|
| authorization | Every note route 403s a foreign note; admin gating; role changes apply to already-issued tokens |
| session | Deleted, suspended, malformed and expired sessions rejected; bad input is 4xx, never 500 |
| dataCleanup | Each delete path leaves zero orphans; streaks survive; backlinks are pruned |
| security | XSS vectors stripped; prototype-chain filter bypass closed; archive/trash exclusivity |
| imageSignature | Signatures bound to filename and expiry; unsigned and tampered requests refused |
| passwordReset | Tokens hashed, single-use, expiring; no account-existence oracle; prior sessions revoked |
| googleLink | Identity linking rules, including the account pre-hijacking case |
| account | Password / Google account state; deletion cascade |
| Provider selection; half-configured backends refuse to boot | |
| adminGuards | The last remaining admin can never be demoted or deleted through the admin surface |
| streaks | Note, flashcard and resurfacing streaks stay correct across a real DST boundary |
| shareNote | Full share lifecycle; the public payload never leaks owner identity |
| scheduler | Reminder and digest jobs fire on schedule, dedupe, and no-op cleanly with no email provider |
| templates | Built-in and user-authored templates round-trip through HTML sanitisation intact |
React 19 · Vite · Tailwind CSS 4 · Tiptap · d3-force · cmdk · @react-oauth/google
Node ≥ 20 · Express 5 · Mongoose · Socket.IO · Zod · pino · bcrypt · custom Mongo rate-limit store
Google Gemini (gemini-flash-latest) · 768-dim embeddings · multi-key pool with quota-aware cooldowns
MongoDB Atlas · Cloudflare R2 (S3 API) with local-disk fallback
Vitest · Supertest · mongodb-memory-server — 173 tests, no external dependencies
Vercel (client) · Render (Docker API) · GitHub Actions CI · Gmail SMTP or Resend