A small REST API for personal notes, built with Express 5, Mongoose 9 and JWT auth.
It exists to be read as much as run: a compact, honest example of how to lay out a Node backend — validation in middleware, controllers that only shape responses, one central error handler, and configuration that fails loudly instead of falling back to insecure defaults.
- Features
- Tech stack
- Quick start
- Environment variables
- Project structure
- API reference
- How a request flows
- Error handling
- Security notes
- Roadmap
- License
| JWT authentication | Stateless bearer tokens with a configurable expiry. |
| Password hashing | bcrypt at 10 rounds; the hash is select: false so it never leaves the database by accident. |
| Request validation | Runs in middleware, before anything touches Mongo. Every failure comes back as one errors map keyed by field. |
| Rate limiting | Fixed-window throttle on the auth endpoints, no external dependency. |
| Ownership scoping | Notes are filtered by the authenticated user on both read and delete. |
| Centralised errors | One handler, one response shape, and internals hidden in production. |
| Fail-fast config | A missing JWT_SECRET stops the process at boot instead of silently signing tokens with a guessable default. |
| Transactional email | Optional welcome email via Nodemailer, sent without blocking the response. |
| Graceful shutdown | SIGINT/SIGTERM drain the server and close the Mongo connection. |
- Runtime — Node.js ≥ 20.6 (ES modules)
- Framework — Express 5
- Database — MongoDB with Mongoose 9
- Auth — jsonwebtoken + bcrypt
- Email — Nodemailer (Gmail transport)
- Config — dotenv
Prerequisites: Node.js 20.6+, and MongoDB running locally (or an Atlas connection string).
# 1. Clone
git clone https://github.com/islamashraf2003/mongoose-playground.git
cd mongoose-playground
# 2. Install
npm install
# 3. Configure
cp .env.example .env
# 4. Generate a signing secret and paste it into .env as JWT_SECRET
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
# 5. Run
npm run dev # auto-restarts on file changes
# or
npm startThe API comes up on http://localhost:3000. Confirm it with:
curl http://localhost:3000/health
# {"status":"ok"}Note: the app will refuse to start if
JWT_SECRETis empty. That is deliberate — see Security notes.
Copy .env.example to .env and fill it in. .env is gitignored; never commit it.
| Variable | Required | Default | Description |
|---|---|---|---|
PORT |
no | 3000 |
Port the HTTP server binds to. |
NODE_ENV |
no | development |
Set to production to hide internal error messages. |
MONGO_URI |
no | mongodb://127.0.0.1:27017/mongoose-playground |
MongoDB connection string. |
JWT_SECRET |
yes | — | Secret used to sign tokens. The app exits at boot without it. |
JWT_EXPIRES_IN |
no | 1d |
Token lifetime (15m, 2h, 7d, …). |
EMAIL_USER |
no | — | Gmail address used to send mail. Leave blank to disable email. |
EMAIL_PASSWORD |
no | — | Gmail App Password, not your account password. |
EMAIL_FROM |
no | "Note App" <EMAIL_USER> |
Display sender. |
Email is entirely optional. With EMAIL_USER or EMAIL_PASSWORD blank, sign-up still succeeds and the welcome message is simply skipped.
mongoose-playground/
├── app.js # entry point: middleware, routes, error handler, shutdown
├── .env.example # template for .env
│
└── src/
├── config/
│ └── env.js # every environment read happens here, validated once
│
├── database/
│ └── database.js # mongoose connection
│
├── middleware/
│ ├── authenticate.js # verifies the bearer token, sets req.user
│ ├── rateLimit.js # fixed-window throttle
│ ├── validateSignUp.js # body validation + normalisation
│ └── validateSignIn.js # credential check + token signing
│
├── models/
│ ├── User.js
│ └── Notes.js
│
├── modules/
│ ├── user/
│ │ ├── controllers/users.controllers.js
│ │ └── routes/users.routes.js
│ └── notes/
│ ├── controllers/notes.controllers.js
│ └── routes/note.routes.js
│
└── utilities/
├── appError.js # Error subclass carrying an HTTP status
├── sendEmail.js # nodemailer transport + welcome email
└── emailTemplate.js # HTML email builder, escapes interpolated values
The rule that keeps it tidy: dependencies point one direction — routes → middleware → controllers → models. Nothing points back up. A controller never reads process.env, and a model never knows an HTTP request exists.
Base URL: http://localhost:3000
All request and response bodies are JSON. Protected routes need an Authorization: Bearer <token> header.
| Method | Endpoint | Auth | Description |
|---|---|---|---|
GET |
/health |
— | Liveness check. |
POST |
/users/sign-up |
— | Create an account. |
POST |
/users/sign-in |
— | Exchange credentials for a token. |
GET |
/notes |
✅ | List your notes, newest first. |
POST |
/notes |
✅ | Create a note. |
DELETE |
/notes/:id |
✅ | Delete one of your notes. |
curl http://localhost:3000/health{ "status": "ok" }Creates an account and, if email is configured, sends a welcome message.
Body
| Field | Type | Rules |
|---|---|---|
name |
string | Required, non-empty. Trimmed. |
email |
string | Required, valid format. Trimmed and lower-cased. Must be unique. |
password |
string | Required, minimum 8 characters. |
curl -X POST http://localhost:3000/users/sign-up \
-H "Content-Type: application/json" \
-d '{"name":"Islam","email":"islam@example.com","password":"secret123"}'201 Created
{
"message": "Account created successfully",
"data": {
"_id": "6a834d0dac3b621776faa2b5",
"name": "Islam",
"email": "islam@example.com",
"isConfirmed": false
}
}Errors — 400 validation failed, 409 email already registered, 429 rate limited.
{
"message": "Validation failed",
"errors": {
"email": "Please enter a valid email address",
"password": "Password must be at least 8 characters"
}
}curl -X POST http://localhost:3000/users/sign-in \
-H "Content-Type: application/json" \
-d '{"email":"islam@example.com","password":"secret123"}'200 OK
{
"message": "Signed in successfully",
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}Errors — 400 validation failed, 401 bad credentials, 429 rate limited.
A wrong password and an unregistered email both return the same 401:
{ "message": "Invalid email or password" }That is intentional. Distinguishing them turns the endpoint into an account-enumeration oracle — anyone could check whether an address has an account here.
Save the token for the calls below:
TOKEN="paste-your-token-here"Returns the authenticated user's notes, newest first.
curl http://localhost:3000/notes -H "Authorization: Bearer $TOKEN"200 OK
{
"message": "Notes fetched successfully",
"data": [
{
"_id": "6a834d0eac3b621776faa2b7",
"title": "First note",
"description": "Hello world",
"createdBy": { "_id": "6a834d0dac3b621776faa2b5", "name": "Islam" },
"createdAt": "2026-08-17T18:03:58.070Z",
"updatedAt": "2026-08-17T18:03:58.070Z"
}
]
}Body
| Field | Type | Rules |
|---|---|---|
title |
string | Required. Trimmed. |
description |
string | Required. Trimmed. |
createdBy is taken from the verified token, never from the request body — a client cannot create a note as somebody else.
curl -X POST http://localhost:3000/notes \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{"title":"First note","description":"Hello world"}'201 Created
{
"message": "Note added successfully",
"data": {
"_id": "6a834d0eac3b621776faa2b7",
"title": "First note",
"description": "Hello world",
"createdBy": "6a834d0dac3b621776faa2b5",
"createdAt": "2026-08-17T18:03:58.070Z",
"updatedAt": "2026-08-17T18:03:58.070Z"
}
}curl -X DELETE http://localhost:3000/notes/6a834d0eac3b621776faa2b7 \
-H "Authorization: Bearer $TOKEN"200 OK
{ "message": "Note deleted successfully", "data": { "...": "the deleted note" } }The query matches on _id and createdBy, so another user's note returns 404 Note not found rather than 403. An attacker learns nothing about which ids exist.
Errors — 400 malformed id, 401 missing/invalid token, 404 not found or not yours.
Take POST /notes as the example:
request
│
▼
express.json() parse body, reject payloads over 100kb
│
▼
authenticate verify Bearer token → req.user = { id }
│ no/expired/invalid token → 401, stops here
▼
addNote (controller) read req.body, write via the model
│ createdBy comes from req.user, not the client
▼
201 response
Anything thrown or passed to next(error) skips straight to the error handler at the bottom of app.js. An unmatched URL falls through to a 404 AppError first.
Every failure leaves through one handler, so the shape is predictable:
{ "message": "human readable description" }Validation failures add a per-field map:
{ "message": "Validation failed", "errors": { "email": "Email is required" } }| Status | When |
|---|---|
400 |
Validation failed, malformed JSON, or a bad ObjectId. |
401 |
Missing, malformed, expired, or invalid token; bad credentials. |
404 |
Unknown route, or a note that does not exist or is not yours. |
409 |
Email already registered. |
429 |
Rate limit exceeded — see the Retry-After header. |
500 |
Something unexpected. |
5xx responses are logged in full on the server. In production the client only sees "Internal server error", because raw error text leaks stack traces, driver internals and fragments of queries.
What this project does, and where it stops.
Implemented
- Passwords hashed with bcrypt;
select: falsekeeps the hash out of ordinary queries. JWT_SECREThas no default. There is no fallback to guess.- Tokens carry an expiry (
JWT_EXPIRES_IN). - Sign-in returns an identical response for unknown emails and wrong passwords, and compares against a dummy hash when the account is missing so the two paths take the same time.
- Auth endpoints are rate limited (10 requests / 15 minutes / IP).
- Note reads and deletes are scoped to the token's owner.
- JSON bodies capped at 100kb.
- Values interpolated into email HTML are escaped.
Not implemented — add these before real traffic
- No refresh tokens or revocation; a stolen token is valid until it expires.
- Rate-limit counters live in process memory, so they reset on restart and are per-instance behind a load balancer. Swap in
express-rate-limitwith Redis when you scale out. - No
helmet, CORS policy, or HTTPS enforcement. isConfirmedis recorded but never enforced — see Roadmap.- No password reset flow.
- No tests.
If you fork this: generate your own
JWT_SECRET, and use a Gmail App Password rather than your account password. Should a credential ever reach a commit, rotate it at the provider first — deleting the line does not remove it from git history.
- Email OTP verification for sign-up — designed in
docs/superpowers/specs/2026-08-17-signup-otp-design.md, which is whatisConfirmedis waiting for - Update a note (
PATCH /notes/:id) - Pagination and search on the notes list
- Password reset by email
- Integration tests
-
helmet+ CORS configuration
MIT — see LICENSE.