Skip to content

Repository files navigation

Mongoose Playground

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.

Node Express MongoDB License


Table of contents


Features

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.

Tech stack

  • Runtime — Node.js ≥ 20.6 (ES modules)
  • Framework — Express 5
  • Database — MongoDB with Mongoose 9
  • Auth — jsonwebtoken + bcrypt
  • Email — Nodemailer (Gmail transport)
  • Config — dotenv

Quick start

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 start

The 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_SECRET is empty. That is deliberate — see Security notes.


Environment variables

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.


Project structure

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.


API reference

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.

GET /health

curl http://localhost:3000/health
{ "status": "ok" }

POST /users/sign-up

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
  }
}

Errors400 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"
  }
}

POST /users/sign-in

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..."
}

Errors400 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"

GET /notes

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"
    }
  ]
}

POST /notes

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"
  }
}

DELETE /notes/:id

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.

Errors400 malformed id, 401 missing/invalid token, 404 not found or not yours.


How a request flows

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.


Error handling

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.


Security notes

What this project does, and where it stops.

Implemented

  • Passwords hashed with bcrypt; select: false keeps the hash out of ordinary queries.
  • JWT_SECRET has 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-limit with Redis when you scale out.
  • No helmet, CORS policy, or HTTPS enforcement.
  • isConfirmed is 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.


Roadmap

  • Email OTP verification for sign-up — designed in docs/superpowers/specs/2026-08-17-signup-otp-design.md, which is what isConfirmed is waiting for
  • Update a note (PATCH /notes/:id)
  • Pagination and search on the notes list
  • Password reset by email
  • Integration tests
  • helmet + CORS configuration

License

MIT — see LICENSE.

About

RESTful Notes API built with Node.js, Express, MongoDB, and Mongoose, featuring JWT authentication and request validation.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages