10 · Capstone Project¶
This is the final project of the entire Master path — and of the whole JavaScript Mastery curriculum. It pulls together everything from all four levels into one production-shaped service: a REST API with a real data layer, JWT authentication, automated tests, a container image, and a CI pipeline that builds and verifies all of it.
What you'll build¶
A Task Tracker API — users register, log in, and manage their own tasks through a JWT-protected REST API backed by SQLite (swappable for Postgres/ MySQL without touching business logic, since the repository layer is the only place that knows about SQL).
Project layout¶
task-tracker-api/
src/
app.js # Express app wiring — routes, middleware, error handling
server.js # entry point — starts the HTTP server
db.js # SQLite connection + schema setup
config.js # environment-driven configuration
auth/
auth.routes.js # /auth/register, /auth/login
auth.middleware.js # JWT verification middleware
tokens.js # sign/verify JWT helpers
users/
users.repository.js
tasks/
tasks.routes.js
tasks.repository.js
tests/
tasks.test.js # Jest + supertest integration tests
Dockerfile
docker-compose.yml
package.json
.env.example
.github/
workflows/
ci.yml
package.json¶
{
"name": "task-tracker-api",
"version": "1.0.0",
"type": "module",
"main": "src/server.js",
"scripts": {
"start": "node src/server.js",
"dev": "node --watch src/server.js",
"lint": "eslint src tests",
"test": "NODE_ENV=test jest --runInBand"
},
"dependencies": {
"better-sqlite3": "^11.3.0",
"bcrypt": "^5.1.1",
"express": "^4.19.2",
"jsonwebtoken": "^9.0.2"
},
"devDependencies": {
"eslint": "^9.9.0",
"jest": "^29.7.0",
"supertest": "^7.0.0"
}
}
src/config.js — environment-driven configuration¶
// src/config.js
export const config = {
port: process.env.PORT || 3000,
jwtSecret: process.env.JWT_SECRET || "dev-secret-change-me",
dbPath: process.env.DB_PATH || (process.env.NODE_ENV === "test" ? ":memory:" : "./data/tasks.db"),
};
src/db.js — SQLite connection and schema¶
better-sqlite3 gives synchronous, fast access to a file-based (or in-memory,
for tests) SQLite database — no separate server process to run locally.
// src/db.js
import Database from "better-sqlite3";
import { config } from "./config.js";
export const db = new Database(config.dbPath);
db.pragma("journal_mode = WAL");
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id),
title TEXT NOT NULL,
done INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
`);
src/users/users.repository.js — the only file that knows SQL for users¶
// src/users/users.repository.js
import { db } from "../db.js";
export const usersRepository = {
findByEmail(email) {
return db.prepare("SELECT * FROM users WHERE email = ?").get(email);
},
create({ email, passwordHash }) {
const result = db
.prepare("INSERT INTO users (email, password_hash) VALUES (?, ?)")
.run(email, passwordHash);
return { id: result.lastInsertRowid, email };
},
};
src/auth/tokens.js — JWT signing and verification¶
// src/auth/tokens.js
import jwt from "jsonwebtoken";
import { config } from "../config.js";
export function signToken(user) {
return jwt.sign({ sub: user.id, email: user.email }, config.jwtSecret, {
expiresIn: "1h",
});
}
export function verifyToken(token) {
return jwt.verify(token, config.jwtSecret); // throws if invalid/expired
}
src/auth/auth.middleware.js¶
// src/auth/auth.middleware.js
import { verifyToken } from "./tokens.js";
export function requireAuth(req, res, next) {
const header = req.headers.authorization;
const token = header?.startsWith("Bearer ") ? header.slice(7) : null;
if (!token) {
return res.status(401).json({ error: "missing authorization token" });
}
try {
req.user = verifyToken(token); // { sub, email, iat, exp }
next();
} catch {
res.status(401).json({ error: "invalid or expired token" });
}
}
src/auth/auth.routes.js — register and login¶
// src/auth/auth.routes.js
import { Router } from "express";
import bcrypt from "bcrypt";
import { usersRepository } from "../users/users.repository.js";
import { signToken } from "./tokens.js";
export const authRouter = Router();
const SALT_ROUNDS = 10;
authRouter.post("/register", async (req, res) => {
const { email, password } = req.body;
if (!email || !password || password.length < 8) {
return res.status(400).json({ error: "email and an 8+ character password are required" });
}
if (usersRepository.findByEmail(email)) {
return res.status(409).json({ error: "email already registered" });
}
const passwordHash = await bcrypt.hash(password, SALT_ROUNDS);
const user = usersRepository.create({ email, passwordHash });
const token = signToken(user);
res.status(201).json({ token, user: { id: user.id, email: user.email } });
});
authRouter.post("/login", async (req, res) => {
const { email, password } = req.body;
const user = usersRepository.findByEmail(email);
if (!user || !(await bcrypt.compare(password, user.password_hash))) {
return res.status(401).json({ error: "invalid email or password" });
}
const token = signToken(user);
res.json({ token, user: { id: user.id, email: user.email } });
});
src/tasks/tasks.repository.js and tasks.routes.js — the protected resource¶
// src/tasks/tasks.repository.js
import { db } from "../db.js";
export const tasksRepository = {
listForUser(userId) {
return db.prepare("SELECT * FROM tasks WHERE user_id = ? ORDER BY id").all(userId);
},
create(userId, title) {
const result = db
.prepare("INSERT INTO tasks (user_id, title) VALUES (?, ?)")
.run(userId, title);
return { id: result.lastInsertRowid, userId, title, done: false };
},
findOwnedById(userId, id) {
return db.prepare("SELECT * FROM tasks WHERE id = ? AND user_id = ?").get(id, userId);
},
setDone(userId, id, done) {
const result = db
.prepare("UPDATE tasks SET done = ? WHERE id = ? AND user_id = ?")
.run(done ? 1 : 0, id, userId);
return result.changes > 0; // false means no matching row for this user — not found or not owned
},
};
// src/tasks/tasks.routes.js
import { Router } from "express";
import { requireAuth } from "../auth/auth.middleware.js";
import { tasksRepository } from "./tasks.repository.js";
export const tasksRouter = Router();
tasksRouter.use(requireAuth); // every route below requires a valid JWT
tasksRouter.get("/", (req, res) => {
const tasks = tasksRepository.listForUser(req.user.sub);
res.json(tasks);
});
tasksRouter.post("/", (req, res) => {
const { title } = req.body;
if (!title || !title.trim()) {
return res.status(400).json({ error: "title is required" });
}
const task = tasksRepository.create(req.user.sub, title.trim());
res.status(201).json(task);
});
tasksRouter.patch("/:id/done", (req, res) => {
const { id } = req.params;
const { done } = req.body;
// findOwnedById scopes by user_id — this is what stops one user from
// reading or editing another user's tasks by guessing IDs (see Module 4)
const task = tasksRepository.findOwnedById(req.user.sub, id);
if (!task) {
return res.status(404).json({ error: "task not found" });
}
tasksRepository.setDone(req.user.sub, id, Boolean(done));
res.json({ ...task, done: Boolean(done) });
});
src/app.js — wiring it all together¶
// src/app.js
import express from "express";
import { authRouter } from "./auth/auth.routes.js";
import { tasksRouter } from "./tasks/tasks.routes.js";
export const app = express();
app.use(express.json());
app.get("/health", (req, res) => res.json({ status: "ok" }));
app.use("/auth", authRouter);
app.use("/tasks", tasksRouter);
// Centralized error handler — catches anything thrown/rejected in routes above
app.use((err, req, res, next) => {
console.error(err);
res.status(500).json({ error: "internal server error" });
});
src/server.js — entry point¶
// src/server.js
import { app } from "./app.js";
import { config } from "./config.js";
app.listen(config.port, () => {
console.log(`task-tracker-api listening on port ${config.port}`);
});
tests/tasks.test.js — Jest + supertest¶
Because db.js reads config.dbPath, and config.js uses an in-memory
SQLite database whenever NODE_ENV=test, the test suite runs against a real,
fresh database with zero setup — no mocking the data layer.
// tests/tasks.test.js
import request from "supertest";
import { app } from "../src/app.js";
async function registerAndLogin(email = "ada@example.com", password = "s3curepass") {
const response = await request(app).post("/auth/register").send({ email, password });
return response.body.token;
}
describe("auth", () => {
it("registers a new user and returns a token", async () => {
const response = await request(app)
.post("/auth/register")
.send({ email: "grace@example.com", password: "s3curepass" });
expect(response.status).toBe(201);
expect(response.body.token).toBeDefined();
expect(response.body.user.email).toBe("grace@example.com");
});
it("rejects duplicate registration with 409", async () => {
await request(app).post("/auth/register").send({ email: "dup@example.com", password: "s3curepass" });
const response = await request(app).post("/auth/register").send({ email: "dup@example.com", password: "s3curepass" });
expect(response.status).toBe(409);
});
it("rejects login with a wrong password", async () => {
await request(app).post("/auth/register").send({ email: "bob@example.com", password: "s3curepass" });
const response = await request(app).post("/auth/login").send({ email: "bob@example.com", password: "wrong" });
expect(response.status).toBe(401);
});
});
describe("tasks", () => {
it("rejects unauthenticated requests", async () => {
const response = await request(app).get("/tasks");
expect(response.status).toBe(401);
});
it("creates and lists tasks for the authenticated user only", async () => {
const token = await registerAndLogin("owner@example.com");
await request(app)
.post("/tasks")
.set("Authorization", `Bearer ${token}`)
.send({ title: "Write capstone tests" });
const response = await request(app)
.get("/tasks")
.set("Authorization", `Bearer ${token}`);
expect(response.status).toBe(200);
expect(response.body).toHaveLength(1);
expect(response.body[0]).toMatchObject({ title: "Write capstone tests", done: 0 });
});
it("marks a task done", async () => {
const token = await registerAndLogin("done-test@example.com");
const createResponse = await request(app)
.post("/tasks")
.set("Authorization", `Bearer ${token}`)
.send({ title: "Ship it" });
const taskId = createResponse.body.id;
const patchResponse = await request(app)
.patch(`/tasks/${taskId}/done`)
.set("Authorization", `Bearer ${token}`)
.send({ done: true });
expect(patchResponse.status).toBe(200);
expect(patchResponse.body.done).toBe(true);
});
it("returns 404 when patching another user's task", async () => {
const ownerToken = await registerAndLogin("owner2@example.com");
const createResponse = await request(app)
.post("/tasks")
.set("Authorization", `Bearer ${ownerToken}`)
.send({ title: "Private task" });
const strangerToken = await registerAndLogin("stranger@example.com");
const response = await request(app)
.patch(`/tasks/${createResponse.body.id}/done`)
.set("Authorization", `Bearer ${strangerToken}`)
.send({ done: true });
expect(response.status).toBe(404); // ownership check in findOwnedById does its job
});
});
Dockerfile¶
# Dockerfile
FROM node:20-slim AS base
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY src ./src
ENV NODE_ENV=production
ENV PORT=3000
EXPOSE 3000
CMD ["node", "src/server.js"]
docker-compose.yml¶
# docker-compose.yml — runs the API with a persisted SQLite file on a volume
services:
api:
build: .
ports:
- "3000:3000"
environment:
JWT_SECRET: ${JWT_SECRET:-dev-secret-change-me}
DB_PATH: /data/tasks.db
PORT: 3000
volumes:
- task-data:/data
volumes:
task-data:
.env.example¶
.github/workflows/ci.yml¶
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
lint-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm run lint
- run: npm test
docker-build:
runs-on: ubuntu-latest
needs: lint-and-test
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- run: docker build -t task-tracker-api:${{ github.sha }} .
Running it locally¶
npm install
cp .env.example .env # then edit JWT_SECRET
npm test # runs the full Jest + supertest suite against an in-memory DB
npm run dev # starts the API on http://localhost:3000
# try it end-to-end with curl
curl -X POST http://localhost:3000/auth/register \
-H "Content-Type: application/json" \
-d '{"email":"ada@example.com","password":"s3curepass"}'
# => { "token": "...", "user": { "id": 1, "email": "ada@example.com" } }
TOKEN="paste-the-token-here"
curl -X POST http://localhost:3000/tasks \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{"title":"Finish the Master path"}'
curl http://localhost:3000/tasks -H "Authorization: Bearer $TOKEN"
Running it with Docker¶
docker compose up --build
# API available at http://localhost:3000, SQLite file persisted in the
# "task-data" named volume across container restarts
How It Actually Works¶
This capstone's request path is the same event-loop story from every earlier lesson,
now end to end under real constraints: an incoming request hits Express's middleware
chain (JWT auth middleware included) running synchronously on the main thread; the JWT
verification step is itself synchronous CPU work (an HMAC or RSA signature check) that
briefly blocks the loop, which is why extremely high-throughput auth services sometimes
offload verification to worker threads; then the route handler's await on a database
call yields control back to the event loop via the microtask/promise machinery, letting
other requests' middleware run while this one's query is in flight over the actual TCP
socket to SQLite/Postgres; the response only gets written once that awaited promise's
microtask resumes execution.
Running this stack in Docker doesn't change any of that JS-level behavior — the
container just gives the Node process (and its one event loop, one V8 heap) an isolated
filesystem, network namespace, and resource limits, imposed by the OS kernel via
cgroups/namespaces, entirely outside V8's awareness. A container's memory limit
matters concretely here: V8 sizes its default heap limits partly based on available
system memory at startup, so a container with a low memory cap can cause V8 to trigger
garbage collection more aggressively (or crash with an out-of-memory error) well before
the same code would under equivalent unconstrained conditions — which is why
production Node containers often pass --max-old-space-size explicitly rather than
trusting V8's auto-detected default.
Where to go from here¶
If you want to keep extending this capstone as a portfolio piece: swap
better-sqlite3 for Postgres via a repository-layer change only (the routes
and services shouldn't need to change — that's the point of the layered
architecture from Module 1); add structured
logging and a /health/ready check from Module 9; add
rate limiting to /auth/login from Module 4; and
add a Playwright E2E suite driving a small frontend against this API, wired
into the CI workflow as its own job, per Module 5
and Module 8.
Congratulations¶
You've completed the full JavaScript Mastery Path — from your first
console.log in Level 1 through closures, async patterns, and design
patterns in Levels 2 and 3, to architecture, security, scaling,
observability, and a real production-shaped service here in Level 4. The
skills in this capstone — layered code you can test in isolation,
authentication done properly, a CI pipeline that actually gates bad changes,
and a container image anyone can run with one command — are exactly what
separates a script that works on your machine from a service a team can run
in production. Take this project, make it yours, and keep building.