CodingNic

Capstone: A Secure, Tested, Authenticated API

Assembling the Complete API

Capstone: A Secure, Tested, Authenticated API 30 min read

Assembling the Complete API

Objectives

By the end of this lesson, you should be able to:

  • Organize a full authenticated API across separate, focused files
  • Wire hashing, JWTs, roles, ownership, validation, rate limiting, and logging into one Express app
  • Use a factory function to build fresh, isolated app instances instead of one shared module-level app

๐Ÿ’ก Why this matters: Every module in this course built one piece of this API in isolation. This lesson puts them all in the same project, a small Notes API, registration, login, role-gated admin access, and ownership-checked notes, exactly the shape of a real, small authenticated service.

โš ๏ธ A note on verification: every file below was actually run, and every route in it is covered by the test suite in the next lesson.

The Project

A small Notes API: users register and log in, each user can create, read, and delete their own notes, an admin role can view any note and list all users. Split across focused files, exactly the separation of concerns this course has used throughout.

text
capstone/
โ”œโ”€โ”€ schemas.js      Zod schemas (Module 5)
โ”œโ”€โ”€ validate.js      validation middleware (Module 5)
โ”œโ”€โ”€ errors.js         AppError class (Module 6, Module 9)
โ”œโ”€โ”€ auth.js           JWTs, requireAuth, requireRole (Module 3, Module 4)
โ”œโ”€โ”€ app.js             the Express app itself, assembled from the above
โ”œโ”€โ”€ app.test.js       integration tests (Module 8)
โ””โ”€โ”€ schemas.test.js   unit tests (Module 7)

Schemas and Validation

javascript
// schemas.js
const { z } = require('zod');

const registerSchema = z.object({
  email: z.string().email(),
  password: z.string().min(8, 'Password must be at least 8 characters')
});

const loginSchema = z.object({
  email: z.string().email(),
  password: z.string().min(1, 'Password is required')
});

const noteSchema = z.object({
  title: z.string().min(3, 'Title must be at least 3 characters').max(100),
  body: z.string().min(1, 'Body is required')
});

module.exports = { registerSchema, loginSchema, noteSchema };
javascript
// validate.js
function validate(schema) {
  return (req, res, next) => {
    const result = schema.safeParse(req.body);
    if (!result.success) {
      const error = new Error('Invalid request data');
      error.status = 400;
      error.isOperational = true;
      error.details = result.error.issues.map(i => ({ field: i.path.join('.'), message: i.message }));
      return next(error);
    }
    req.validatedBody = result.data;
    next();
  };
}

module.exports = { validate };

validate now passes its error to next(error) instead of responding directly, this is what lets the shared error handler (below) log every kind of failure consistently, validation included, rather than validation errors bypassing the logging this course built in Module 9.

A Shared Error Type

javascript
// errors.js
class AppError extends Error {
  constructor(message, status) {
    super(message);
    this.status = status;
    this.isOperational = true;
  }
}

module.exports = { AppError };

Authentication and Authorization

javascript
// auth.js
const jwt = require('jsonwebtoken');
const { AppError } = require('./errors');

const JWT_SECRET = process.env.JWT_SECRET || 'test-secret-for-this-course';

function issueToken(user) {
  return jwt.sign({ sub: user.id, email: user.email, role: user.role }, JWT_SECRET, { expiresIn: '1h' });
}

function requireAuth(req, res, next) {
  const header = req.headers.authorization;
  if (!header || !header.startsWith('Bearer ')) {
    return next(new AppError('Missing or invalid Authorization header', 401));
  }
  try {
    req.user = jwt.verify(header.slice(7), JWT_SECRET);
    next();
  } catch (err) {
    return next(new AppError('Invalid or expired token', 401));
  }
}

function requireRole(role) {
  return (req, res, next) => {
    if (req.user.role !== role) {
      return next(new AppError(`Requires ${role} role`, 403));
    }
    next();
  };
}

module.exports = { issueToken, requireAuth, requireRole, JWT_SECRET };

Notice JWT_SECRET is read from process.env, falling back to a fixed value only for this course’s own testing, in a real deployment the environment variable (Module 6) would always be set, never the fallback.

The App Itself, as a Factory

javascript
// app.js
const express = require('express');
const bcrypt = require('bcryptjs');
const pino = require('pino');
const pinoHttp = require('pino-http');
const rateLimit = require('express-rate-limit');

const { registerSchema, loginSchema, noteSchema } = require('./schemas');
const { validate } = require('./validate');
const { AppError } = require('./errors');
const { issueToken, requireAuth, requireRole } = require('./auth');

function createApp({ authLimiterMax = 5 } = {}) {
  const logger = pino({ level: process.env.LOG_LEVEL || 'silent' });
  const app = express();
  app.use(express.json());
  app.use(pinoHttp({ logger }));

  // In-memory "database", scoped to this app instance, not shared module state.
  let users = [];
  let notes = [];
  let nextUserId = 1;
  let nextNoteId = 1;

  const authLimiter = rateLimit({
    windowMs: 15 * 60 * 1000,
    max: authLimiterMax,
    standardHeaders: true,
    legacyHeaders: false,
    handler: (req, res) => {
      req.log.warn({ status: 429 }, 'Rate limit exceeded on auth route');
      res.status(429).json({ error: 'TooManyRequests', message: 'Too many attempts, try again later' });
    }
  });

  app.post('/api/v1/auth/register', authLimiter, validate(registerSchema), async (req, res, next) => {
    const { email, password } = req.validatedBody;
    if (users.find(u => u.email === email)) {
      return next(new AppError('Email already registered', 409));
    }
    const passwordHash = await bcrypt.hash(password, 10);
    const user = { id: nextUserId++, email, passwordHash, role: 'member' };
    users.push(user);
    req.log.info({ userId: user.id }, 'User registered');
    res.status(201).json({ id: user.id, email: user.email, role: user.role });
  });

  app.post('/api/v1/auth/login', authLimiter, validate(loginSchema), async (req, res, next) => {
    const { email, password } = req.validatedBody;
    const user = users.find(u => u.email === email);
    if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
      return next(new AppError('Incorrect email or password', 401));
    }
    req.log.info({ userId: user.id }, 'User logged in');
    res.json({ token: issueToken(user) });
  });

  app.post('/api/v1/notes', requireAuth, validate(noteSchema), (req, res) => {
    const note = { id: nextNoteId++, authorId: req.user.sub, ...req.validatedBody };
    notes.push(note);
    res.status(201).json(note);
  });

  app.get('/api/v1/notes/:id', requireAuth, (req, res, next) => {
    const note = notes.find(n => n.id === Number(req.params.id));
    if (!note) return next(new AppError('Note not found', 404));
    const isOwner = note.authorId === req.user.sub;
    const isAdmin = req.user.role === 'admin';
    if (!isOwner && !isAdmin) {
      return next(new AppError('You do not own this note', 403));
    }
    res.json(note);
  });

  app.delete('/api/v1/notes/:id', requireAuth, (req, res, next) => {
    const note = notes.find(n => n.id === Number(req.params.id));
    if (!note) return next(new AppError('Note not found', 404));
    const isOwner = note.authorId === req.user.sub;
    const isAdmin = req.user.role === 'admin';
    if (!isOwner && !isAdmin) {
      return next(new AppError('You do not own this note', 403));
    }
    notes = notes.filter(n => n.id !== note.id);
    res.status(204).send();
  });

  app.get('/api/v1/admin/users', requireAuth, requireRole('admin'), (req, res) => {
    res.json(users.map(u => ({ id: u.id, email: u.email, role: u.role })));
  });

  app.use((err, req, res, next) => {
    const status = err.status || 500;
    if (err.isOperational) {
      req.log.warn({ status, err: err.message }, 'Operational error handled');
    } else {
      req.log.error({ status, err }, 'Unexpected error, not marked operational');
    }
    const body = { error: err.isOperational ? err.message : 'Internal server error' };
    if (err.details) body.details = err.details;
    res.status(status).json(body);
  });

  // Testing-only hook, not an HTTP route, never reachable from outside the process.
  app.locals.testHelpers = {
    promoteToAdmin(email) {
      const user = users.find(u => u.email === email);
      if (user) user.role = 'admin';
    }
  };

  return app;
}

module.exports = { createApp };

Why a Factory Function, Not a Single Exported App

Every earlier module in this course exported one app, a module-level constant. Here, createApp(options) builds a brand-new app, with its own in-memory data and its own rate limiter, on every call. This matters specifically for testing, the next lesson creates a fresh app in every single test, so no test’s registered users, notes, or consumed rate-limit quota ever leaks into another test, without needing a reset endpoint or any other test-only route on the real application surface. authLimiterMax being configurable is the same idea applied to the rate limiter specifically, tests that need to trigger a 429 can ask for a strict limit, without that strict limit affecting every other test in the suite.

Try It

  1. Build all five files above, and confirm createApp() returns a working Express app by sending it a single request manually.
  2. Register a user, log in, create a note, and fetch it back, by hand, confirming the full flow works end to end.
  3. Explain, in your own words, why app.locals.testHelpers is a reasonable place for a testing hook, compared to adding a real HTTP route like /__test__/reset that would ship in production.
  4. Identify, for each route in app.js, which earlier module in this course it came from.

Recap

  • This capstone combines hashing (Module 1), JWTs (Module 3), role and ownership checks (Module 4), Zod validation (Module 5), rate limiting (Module 6), and structured logging (Module 9) into one small, real API.
  • createApp(options) is a factory, not a single shared app, each call produces an isolated instance, essential for clean, independent tests.
  • A testing-only hook lives on app.locals, directly accessible to tests that already have the app object, not as a route that would ship in production.

Next lesson: the complete test suite, unit tests for the schemas and integration tests for every route, covering the whole API end to end.