controller-sets v3.3.0

Add a guard#

The library builds routes; deciding who may call them is yours. Pass guards in middlewares and they run before every route on that router — all six.

createRouter({
  model: Order,
  middlewares: [auth.requireAuth, auth.requireRole('staff')],
  allowedFields: ['status', 'note'],
});

auth.requireAuth and auth.requireRole come from createAuthRouter, but any Express middleware works — your own JWT check, express-rate-limit, a tenant resolver. After requireAuth, req.auth holds { userId, role, claims }.

Because middlewares applies to every route, different rules for reads and writes need two routers (below) or hand-wired routes.

Public reads, staff writes#

The most common shape. Mount two routers on one model, each with its own allowlists — share the read configuration, differ on who can write.

const catalogue = {
  model: Product,
  orderBy: '-createdAt',
  query: ['category', 'isFeatured'],
  search: ['name', 'description'],
  filterableFields: ['price', 'category'],
  sortableFields: ['price', 'name', 'createdAt'],
};

// Refuse every method that isn't a read.
const readOnly = (req, res, next) =>
  ['GET', 'HEAD', 'OPTIONS', 'QUERY'].includes(req.method)
    ? next()
    : res.status(405).json({ success: false, error: 'Method not allowed.' });

// Anyone: read
app.use('/api/products', createRouter({ ...catalogue, middlewares: [readOnly], allowedFields: [] }));

// Staff: read and write
app.use('/api/admin/products', createRouter({
  ...catalogue,
  middlewares: [auth.requireAuth, auth.requireRole('staff', 'admin')],
  allowedFields: ['name', 'price', 'description', 'category', 'isFeatured'],
}));

allowedFields: [] does not make a router read-only. It stops POST and PATCH from writing any field, but DELETE /:id has no body to filter and still deletes. A public router needs the readOnly guard (or hand-wired GET routes only). Keep allowedFields: [] as well — it's a second lock if the guard is ever removed.

Server-owned fields#

Fields like author, ownerId or tenantId come from the signed-in user, never from the body. Keep them out of allowedFields — so anything a client sends for them is stripped — and set them in validate.create:

app.use('/api/posts', createRouter({
  model: Post,
  middlewares: [auth.requireAuth],
  allowedFields: ['title', 'body', 'tags'],          // no 'author'
  validate: {
    create: (payload, { req }) => ({ ...payload, author: req.auth.userId }),
  },
}));

Users see only their own records#

The library has no concept of ownership, so per-user data takes three pieces. Skip any one and it leaks:

  1. Lists: force the owner filter onto every GET /, and turn off QUERY / (its filter comes from the body, which your middleware doesn't touch).
  2. Single records: check ownership on every /:id — the list filter doesn't cover them.
  3. Creates: stamp the owner from the token.
import express from 'express';
import mongoose from 'mongoose';
import { createRouter, HttpError } from 'express-controller-sets';

// 1. Lists: pin ownerId to the caller. Express 5 re-parses req.query on every
//    read, so assigning to it does nothing — shadow it with a real property.
const onlyMine = (req, res, next) => {
  Object.defineProperty(req, 'query', {
    value: { ...req.query, ownerId: String(req.auth.userId) },
    writable: true, configurable: true, enumerable: true,
  });
  next();
};

// 2. Single records: 404 unless the caller owns it. 404, not 403 — don't
//    confirm that someone else's record exists.
const ownsNote = async (req, res, next) => {
  if (!mongoose.isValidObjectId(req.params.id)) return next();   // the handler answers 400
  const mine = await Note.exists({ _id: req.params.id, ownerId: req.auth.userId });
  if (!mine) throw new HttpError(404, 'Entry not found.');
  next();
};

const notes = express.Router();
notes.use(auth.requireAuth, onlyMine);
notes.use('/:id', ownsNote);
notes.use(createRouter({
  model: Note,
  orderBy: '-createdAt',
  query: ['ownerId'],                                   // the pinned filter must be in query
  enableQuery: false,                                   // 1b. QUERY would bypass onlyMine
  allowedFields: ['title', 'body'],                     // no ownerId
  validate: { create: (p, { req }) => ({ ...p, ownerId: req.auth.userId }) },   // 3. stamp the owner
}));

app.use('/api/notes', notes);

Tested end to end: a user cannot list, read, change or delete another user's notes, even by sending ?ownerId= themselves. With caching on, the pinned filter is part of the cache key, so users never share entries.

Rate limits#

Nothing is rate-limited by default. Add a limiter to middlewares, and a tighter one on anything that gets guessed at — sign-in, password reset:

import rateLimit from 'express-rate-limit';

const api = rateLimit({ windowMs: 60_000, limit: 120 });
createRouter({ model: Product, middlewares: [api], allowedFields: [] });

For the auth routes, see Authentication → Rate-limit sign-in.

Before you go to production#

  1. Every router has allowedFields

    Or blockedFields. No startup warning about unprotected models. Server-owned fields (role, ownerId) are not in it.

  2. Every write is guarded

    Routers that accept POST, PATCH or DELETE have an auth guard in middlewares. Public routers have a readOnly guard.

  3. Per-user data checks /:id

    Owner-scoped routers check ownership on single records and set enableQuery: false.

  4. Middleware order is right

    express.json() before the routers, errorHandler after them.

  5. Secrets come from the environment

    token.secret is 32+ characters and not in the repository.

  6. legacyMode and allowRawRegex are off

    On every public router.

  7. Uploads are private unless meant to be public

    upload.acl: 'public-read' only for content like product photos.

Esc
↑ ↓ to move↵ to open