v3.0 — the security release

One model in,a whole REST API out.

Express Controller Sets is a Node.js library that turns a Mongoose model into five working endpoints — list, read, create, update, delete — with filtering, search, pagination and optional S3 file uploads already wired up. You write the model and the rules; it writes the controller.

What you write

createRouter({
  model: Product,
  query: ['category'],
  search: ['name'],
  allowedFields: ['name', 'price'],
});

What you get

  • GET/productsfilter · search · page
  • POST/productscreate
  • GET/products/:idread one
  • PATCH/products/:idupdate
  • DELETE/products/:iddelete
0 endpoints per call Node 18+ Express 5 · Mongoose 9 TypeScript included MIT licensed

Never built a REST API before? Start here.

Skip ahead if router.get('/:id', …) is muscle memory for you.

The paperwork analogy. Every office form is different, but the clerk's job is always the same: check the form is filled in correctly, file it, find it again later, amend it, shred it.

Your models are the forms. The clerk's job — read, create, update, delete — is code you would otherwise write again for every single model, and get subtly wrong in a different way each time. This library is the clerk. You hand it a model and a list of house rules, and it handles the paperwork identically everywhere.

Words you'll see on every page

Word What it actually means
Model A Mongoose model — the shape of one kind of record, like Product or User. It's the only required option.
Router A bundle of URL routes you attach to your Express app with app.use('/api/products', router).
Controller The functions that actually answer a request. This library generates them for you; the class is called ControllerSets.
CRUD Create, Read, Update, Delete — the four things every API does.
Middleware A function that runs before your handler, typically to check who the caller is. Yours to supply, via the middlewares option.
Allowlist A list of what is permitted. Anything not on the list is refused. This library is built on allowlists — see Nothing is open.
Mass assignment The bug where a client sends {"role":"admin"} and your database cheerfully saves it. allowedFields is what stops it.
ACL Access Control List — on S3, whether an uploaded file is readable by the public. Defaults to private here.

Everything this package generates is a public endpoint. There is no login check, no ownership check and no rate limit unless you add one. It builds the routes; it does not decide who may call them. Read Auth & middlewares before this reaches the internet.

What you actually get

Three exports carry almost all the value. The rest is detail.

createRouter()

Five REST endpoints for one model, ready to mount.

createRouterS3upload()

The same five, plus file uploads to S3 on POST and PATCH.

errorHandler

One Express error handler that turns any failure into consistent JSON.

That is the whole surface for most projects:

import { createRouter, createRouterS3upload, errorHandler } from 'express-controller-sets';

app.use('/api/products', createRouter({ model: Product, /* rules */ }));
app.use('/api/users',    createRouterS3upload({ model: User, /* rules */ }));
app.use(errorHandler);   // always last

Four more exports exist for when you need them: the ControllerSets class (wire handlers into your own routes), fileUploadMiddleware (uploads without the CRUD), compressImage, and HttpError / escapeRegex for your own code.

Install

One command for CRUD. A second one only if you want uploads.

npm install express-controller-sets mongoose express

Uploads need two more packages. They are optional peers — the library imports them lazily, so if you never create an upload router you never pay for them:

npm install multer @aws-sdk/client-s3

# optional: image compression before upload
npm install sharp

Node 18 or newer

Tested on Node 18, 20 and 22 on every commit.

ESM only

Use import, and add "type": "module" to your package.json.

Types built in

No @types/… package to install. Autocomplete works immediately.

Nothing loads unused

sharp ships a native binary and the AWS SDK is large. Neither is imported until an upload actually happens.

Peer dependencies, in plain terms. Express and Mongoose are yours — the library uses the same copy your app already has, so there is never a second, mismatched Mongoose in your node_modules.

Your first API, end to end

Four steps from an empty file to five working endpoints.

  1. Define a Mongoose model as you normally would

    Nothing here is special to this library.

    // models/Product.js
    import mongoose from 'mongoose';
    
    const productSchema = new mongoose.Schema({
      name:        { type: String, required: true },
      price:       { type: Number, required: true },
      description: String,
      category:    { type: mongoose.Schema.Types.ObjectId, ref: 'Category' },
      isFeatured:  { type: Boolean, default: false },
    }, { timestamps: true });
    
    export default mongoose.model('Product', productSchema);
  2. Build a router and state the house rules

    Every option below is an allowlist. Read the comments — they are the whole security model in miniature.

    import express from 'express';
    import { createRouter, errorHandler } from 'express-controller-sets';
    import Product from './models/Product.js';
    
    const app = express();
    app.use(express.json());
    
    const productRouter = createRouter({
      model: Product,
    
      orderBy: '-createdAt',                 // default sort: newest first
    
      // What a client may FILTER on:  /api/products?category=65a…
      query: ['category', 'isFeatured'],
    
      // What a client may SEARCH in:  /api/products?s=laptop
      search: ['name', 'description', 'category.name'],
    
      // What a client may WRITE. Without this line, every schema field
      // is writable — including any you never meant to expose.
      allowedFields: ['name', 'price', 'description', 'category'],
    
      // What a client may use with ?compareField= / ?rangeField= and ?sort=
      filterableFields: ['price', 'category'],
      sortableFields: ['price', 'name', 'createdAt'],
    });
    
    app.use('/api/products', productRouter);
    app.use(errorHandler);                   // must be after your routes
    
    app.listen(3000);
  3. Connect to MongoDB and start the server

    import mongoose from 'mongoose';
    await mongoose.connect(process.env.MONGODB_URI);
  4. Call it

    curl localhost:3000/api/products
    curl localhost:3000/api/products?s=laptop&sort=-price&page=1
    curl -X POST localhost:3000/api/products \
      -H 'Content-Type: application/json' \
      -d '{"name":"Laptop","price":1299}'

If you skipped allowedFields, look at your console. The library prints a warning naming the model, once per process. It is not decoration — until you configure a field policy, a client can write any field your schema defines.

A request, step by step

What happens between GET /api/products?s=laptop and the JSON that comes back.

Your code
Middlewares

Auth, rate limits, tenancy

Library
Build filters

Only allowlisted params

Library
Apply search

Escaped, relations resolved

Your hook
onGet

Choose populate & select

MongoDB
Query

Capped at maxLimit

Library
JSON out

Always the same shape

A write (POST or PATCH) follows the same path with two differences: on an upload router the file middleware runs first and replaces the file with its URL, and the request body is filtered down to the fields you allowed before it reaches Mongoose.

Where do errors go? Deliberate 4xx answers — a bad ID, a field that isn't filterable — are returned by the controller itself, so they work even if you forgot to mount errorHandler. Anything unexpected is re-thrown; Express 5 forwards it to your error handler instead of hanging the request.

Nothing is open until you open it

One idea explains almost every option in this library. Learn it once and the rest of the page reads itself.

The trap

"It works out of the box"

A generated API that exposes everything by default is convenient on day one and a breach on day ninety. Someone filters on passwordResetToken, or POSTs {"role":"admin"}, and nothing stops them.

The rule here

Implicit deny

A field is not filterable, not sortable and not writable until you name it. Reads are capped. Uploads are private. Every convenience is opt-in, in one line.

What is closed, and the key that opens it

A client wants to… Option Default if you say nothing
Filter by a field query Nothing is filterable
Compare or range a field filterableFields Falls back to query
Sort by a field sortableFields Falls back to query + your orderBy field
Search text search Search does nothing
Write a field allowedFields / blockedFields Everything writable — plus a startup warning
Read the whole collection maxLimit Capped at 100 documents
Send a regular expression allowRawRegex Escaped and matched literally
Upload a file type upload.allowedMimeTypes JPEG, PNG, GIF, WebP, AVIF, PDF only
Get a public file URL upload.acl private

The one default that is not closed: writable fields. Requiring allowedFields would break every existing installation on upgrade, so instead the library warns loudly and leaves the door open. If you configure exactly one option from this page, make it this one.

Fields no configuration can unlock

Regardless of what you allow, these are stripped from every request body — they are database bookkeeping or MongoDB syntax, never client data:

_id __v createdAt updatedAt any $-prefixed key any key containing .

Why $ and . matter. To MongoDB they are not characters, they are syntax: $ starts an operator and . reaches into a subdocument. A body of {"$where": "…"} or {"owner.role": "admin"} is an instruction, not data. The check runs on nested values too, up to eight levels deep — beyond that the value is refused rather than vouched for.

Options at a glance

Both router factories take one object. Five keys are consumed by the router itself; everything else is passed straight through to the controller.

createRouterS3upload({
  // ---- router-level ----
  middlewares: [requireAuth],          // run before every route in this router
  path: 'products/',                   // S3 key prefix           (upload routers)
  fields: [{ name: 'image', maxCount: 1 }],  //  form fields      (upload routers)
  imgOptimizations: 'medium',          // compression level       (upload routers)
  upload: { acl: 'private' },          // everything else S3      (upload routers)

  // ---- controller-level ----
  model: Product,                      // the only required option
  orderBy: '-createdAt',
  query: ['category'],
  search: ['name', 'category.name'],
  allowedFields: ['name', 'price'],
  blockedFields: ['isFeatured'],
  filterableFields: ['price'],
  sortableFields: ['price', 'createdAt'],
  maxLimit: 100,
  maxSearchLength: 128,
  allowRawRegex: false,
  lean: false,
  runAfterCreate: async (doc) => {},
  strictAfterCreate: false,
  onGet: async (req, res) => ({ populates: [], selects: '' }),
  logger: console,
  legacyMode: false,
});

Full descriptions and defaults live in Every option. The sections in between explain the ones with real consequences.

The five endpoints

Identical for every model, whether you use createRouter or createRouterS3upload.

Method Path What it does Success
GET / List records. Filter, search, sort, paginate. 200
POST / Create one record from the writable part of the body. 201
GET /:id Fetch a single record by its 24-character ObjectId. 200
PATCH /:id Partially update a record. Returns the updated document. 200
DELETE /:id Permanently delete a record. 200

Mount the router wherever you like — the paths above are relative to the mount point:

app.use('/api/products', createRouter({ model: Product }));
// → GET /api/products, POST /api/products, GET /api/products/:id, …

There is no PUT. Updates are PATCH only, applied with $set, so fields you don't send are left alone. If your client sends PUT, Express answers 404 — change the client, or add your own route.

What "update" really does

The update is a single atomic query (findByIdAndUpdate), not read-then-write, so two simultaneous updates cannot clobber each other in the gap. Schema validators run with context: 'query', which is what makes custom validators that use this behave correctly on updates. A missing record returns 404 rather than creating one.

Filtering

Three ways to narrow a list, each gated by an allowlist.

Every name in query becomes a query-string parameter of the same name.

createRouter({ model: Product, query: ['category', 'isFeatured'] });

// GET /api/products?category=65af…&isFeatured=true
//   → { category: '65af…', isFeatured: 'true' }

// Repeat a parameter to match any of several values:
// GET /api/products?category=65af…&category=65b0…
//   → { category: { $in: ['65af…', '65b0…'] } }

A parameter that isn't in query is ignored entirely — not an error, just no effect. A parameter whose value arrives as an object (which some query-string parsers allow, e.g. ?price[$ne]=0) is rejected with 400, because that shape is how operator injection gets in.

Two parameters working together: which field, and the bounds.

# price between 10 and 100 (inclusive)
GET /api/products?rangeField=price&range=10-100

# 18 and over — omit the upper bound
GET /api/users?rangeField=age&range=18-

# 100 and under — omit the lower bound
GET /api/products?rangeField=price&range=-100

The field must appear in filterableFields (which defaults to query), otherwise the response is 400. The value is split on a single -, so negative bounds are not expressible — use compareField for those.

One operator against one value.

# price > 50
GET /api/products?compareField=price&compareValue=50&compareOperator=gt

# combine freely with equality filters
GET /api/users?status=active&compareField=age&compareValue=30&compareOperator=lte
compareOperator Meaning
gtgreater than
gtegreater than or equal
ltless than
lteless than or equal
nenot equal
eqequal — the default when omitted

Values that look numeric are converted to numbers; everything else stays a string. Same allowlist as ranges: not in filterableFields means 400.

Why filterable fields are allowlisted at all. Suppose your schema has passwordResetToken and your API never returns it. If a client could write ?compareField=passwordResetToken&compareOperator=gt&compareValue=m, the number of results answers "does this token sort after m?" — and a few hundred of those questions reconstruct the token character by character. The data never appears in a response; it leaks through the result count. Allowlisting the field closes it.

Sorting & pagination

Two knobs the client turns, both bounded by you.

Sorting

createRouter({
  model: Product,
  orderBy: '-createdAt',                        // your default: newest first
  sortableFields: ['price', 'name', 'createdAt'],  // what clients may choose
});

// GET /api/products?sort=price     → cheapest first
// GET /api/products?sort=-price    → dearest first  (the '-' means descending)
// GET /api/products?sort=secretKey → 400 Field 'secretKey' is not sortable.

Your own orderBy is never checked against the allowlist — it's your decision, not the client's. Only a client-supplied ?sort= is validated. If you omit sortableFields, it defaults to everything in query plus whatever field orderBy names.

Pagination

GET /api/products?page=2&pageSize=20

Adding ?page= switches the response into paginated form, with a pagination block alongside the data:

{
  "success": true,
  "data": [ /* … */ ],
  "pagination": {
    "currentPage": 2,
    "pageSize": 20,
    "totalPages": 7,
    "totalRecords": 132
  }
}
Situation How many documents come back
No ?page Up to maxLimit — 100 by default. Never the whole collection.
?page=1, no pageSize 50 — the default page size.
?page=1&pageSize=20 20.
?page=1&pageSize=5000 Clamped to maxLimit. A client cannot enlarge its own page.
?page=abc or ?page=-3 Treated as page 1 rather than erroring.

Upgrading and suddenly seeing only 100 rows? That's maxLimit doing its job — in 2.x an unpaginated GET / returned every document in the collection, which is a memory spike waiting for a big table. Paginate, or raise the cap deliberately with maxLimit: 500.

Shaping responses with onGet

One hook that decides, per request, which relations to expand and which fields to return.

Populating relations is expensive, and different callers need different amounts of data. A mobile list view wants names; an admin screen wants everything. onGet lets one router serve both.

onGet: (req, res) => ({
  populates: [],   // Mongoose .populate() argument — string, object, or array
  selects: '',     // Mongoose .select() argument — e.g. 'name price -_id'
})
createRouter({
  model: Product,
  onGet: () => ({
    populates: 'category',
    selects: 'name price category',
  }),
});

The hook receives the live req, so anything your auth middleware attached is available.

createRouter({
  model: Order,
  middlewares: [requireAuth],
  onGet: (req) => {
    if (req.user?.role === 'admin') {
      return {
        populates: [
          { path: 'customer', select: 'name email phone' },
          { path: 'items.product', select: 'name price' },
        ],
        selects: '',                       // everything
      };
    }
    return { populates: 'items.product', selects: '-internalNotes -margin' };
  },
});

Keep list responses light and expand only on the single-record route.

const onGet = async (req) => {
  const isDetail = Boolean(req.params.id);
  return {
    populates: isDetail ? ['category', 'reviews'] : 'category',
    selects: isDetail ? '' : 'name price category',
  };
};

createRouter({ model: Product, onGet });   // reuse across routers
Route onGet applies?
GET /
GET /:id
PATCH /:id (the returned document)
POST /✘ — returns the created document as-is
DELETE /:id✘ — returns a message, not a document

If your hook throws, the request still succeeds. The error is logged and the request falls back to no populate and no select. That keeps a broken hook from taking down every read — but it also means a hook you rely on to hide fields could silently stop hiding them. Use selects for convenience; use schema toJSON transforms or a separate router for anything that must never be returned.

A note on lean. Reads return real Mongoose documents by default, so schema.set('toJSON', { transform }) runs and any fields you strip there really are stripped. lean: true is faster but bypasses those transforms — only enable it if you have checked that nothing sensitive was relying on them.

Create & update

The request body is filtered before Mongoose ever sees it. This section is the single most important one for security.

Say what a client may write

// Allowlist — name what IS writable (recommended)
createRouter({
  model: User,
  allowedFields: ['name', 'email', 'avatar'],
});

// Blocklist — name what is NOT writable
createRouter({
  model: User,
  blockedFields: ['role', 'isAdmin', 'balance'],
});

// Per verb — stricter on update than on create
createRouter({
  model: User,
  allowedFields: {
    create: ['name', 'email', 'password'],
    update: ['name', 'avatar'],          // email and password need their own flow
  },
});

Both options may be combined; blockedFields is applied after allowedFields, so a field on both lists is blocked.

Mongoose's strict mode is not a substitute for this. Strict mode drops keys your schema doesn't define — it throws away typos and faithfully saves {"role": "admin"}, because role is a real field. The only thing standing between a signup form and an admin account is a field policy.

What a client sends vs. what gets saved

// allowedFields: ['name', 'email']

// The client POSTs:
{
  "name":  "Ayesha",
  "email": "ayesha@example.com",
  "role":  "admin",          // ✘ not allowlisted        → dropped
  "_id":   "65af…",          // ✘ always immutable       → dropped
  "$where": "1 === 1"        // ✘ Mongo operator syntax  → dropped
}

// What reaches the database:
{ "name": "Ayesha", "email": "ayesha@example.com" }

Dropped fields are dropped quietly — the request still succeeds with the fields that survived. One exception: if nothing survives, you get 400 "Request body contains no writable fields." rather than an empty record being created.

Debugging a field that "won't save"? Nine times out of ten it isn't in allowedFields. Check that list first, then check for a typo against the schema.

The after-create hook

Run something once a record exists — send a welcome email, push a job onto a queue, warm a cache.

createRouter({
  model: User,
  allowedFields: ['name', 'email'],

  runAfterCreate: async (user) => {
    await sendWelcomeEmail(user.email);
    await queue.add('index-user', { id: user._id });
  },
});

The hook receives the created document and is awaited before the 201 goes out.

Default

Failures are logged, not raised

If the email service is down, the user is still created and the client still gets 201. The record exists, so reporting failure would tell the client to retry a write that already landed.

strictAfterCreate: true

Failures propagate

The error reaches your errorHandler and the client sees a 500 — even though the record was created. Choose this only when a missed side effect is worse than a confusing response.

Keep it short. The client is waiting on this hook. Anything slow or failure-prone belongs on a queue — enqueue inside the hook, do the work elsewhere.

Errors & status codes

Every failure comes back in the same shape. Mount the handler once and stop writing try/catch in route files.

import { errorHandler } from 'express-controller-sets';

app.use('/api/products', productRouter);
app.use('/api/users', userRouter);

app.use(errorHandler);   // AFTER all routes — Express requires this order

The error response

{
  "success": false,
  "error": "Validation Error: price is required",
  "requestId": "0f2a5b8c-…"
}

requestId is echoed into your server log alongside the full message and stack. When a user reports "it said Internal Server Error", that ID takes you straight to the line. A stack field is added only when NODE_ENV=development.

What each status code means

Code When you'll see it Who should fix it
200 Successful read, update or delete.
201 Record created.
400 Malformed ID, a field that isn't filterable or sortable, an invalid filter value, a search term over 128 characters, a body with no writable fields, a schema validation failure, a rejected file type, or a Multer limit. The caller
404 No record with that ID — on read, update or delete. The caller
409 A unique index rejected the write. The message names the conflicting fields. The caller
500 Anything unclassified. The client gets "Internal Server Error" and a requestId; the details stay in your log. You
503 An upload route was called but S3 environment variables are missing. You

Why 5xx responses are deliberately vague. Mongoose and the AWS SDK write connection strings, internal hostnames, bucket names and index definitions into err.message. Echoing that to the client hands an attacker a map of your infrastructure. Deliberate 4xx messages — the ones this library authors — are returned in full, because they were written to be read by a caller.

Duplicate keys are a 409, not a 500

{ "success": false, "error": "Duplicate value for: email.", "requestId": "…" }

A unique-constraint collision is something the caller can fix by sending a different value, so it is reported as a conflict rather than a server fault. Your front end can show "that email is already registered" without parsing message text.

How uploads work

Swap one function name and POST/PATCH start accepting files.

import { createRouterS3upload } from 'express-controller-sets';

const userRouter = createRouterS3upload({
  model: User,
  allowedFields: ['name', 'email', 'avatar'],   // 'avatar' must be writable!

  path: 'avatars/',                             // key prefix inside the bucket
  fields: [{ name: 'avatar', maxCount: 1 }],    // the multipart field name
  imgOptimizations: 'medium',                   // optional compression
});

app.use('/api/users', userRouter);

From the browser, send multipart/form-data — regular fields and the file together:

const form = new FormData();
form.append('name', 'Ayesha');
form.append('avatar', fileInput.files[0]);

await fetch('/api/users', { method: 'POST', body: form });
// Don't set Content-Type yourself — the browser adds the multipart boundary.

What happens to the file

  1. Received into memory

    Multer buffers it — no temp files on disk. Size and count limits apply here.

  2. The bytes are identified

    The library reads the file's own signature. The Content-Type the client sent is ignored: it is a claim, and the bytes are the fact. An unrecognised file is rejected with 400.

  3. Checked against the allowlist

    Only the detected type is compared to allowedMimeTypes. Renaming payload.html to photo.png changes nothing.

  4. Optionally compressed

    JPEG, PNG and WebP are re-encoded if imgOptimizations is set. See Image optimization.

  5. Uploaded with a safe key and headers

    Stored as <path>/<timestamp>-<random>.<ext>, with the extension and Content-Type derived from the final bytes, an ACL of private, and Content-Disposition: attachment for anything not on the inline-safe list.

  6. The URL replaces the file in req.body

    The CRUD controller then saves it like any other field — which is why the field must appear in allowedFields.

// After the middleware, before the controller:
req.body.avatar === 'https://your-endpoint/bucket/avatars/1721736000000-482913746.jpg'

// Prefer an object? Set formatToUrlObject on the field:
fields: [{ name: 'avatar', maxCount: 1, formatToUrlObject: true }]
req.body.avatar === { url: 'https://…' }

// maxCount > 1 gives you an array:
fields: [{ name: 'gallery', maxCount: 5 }]
req.body.gallery === ['https://…', 'https://…']

Why the client's Content-Type is never trusted. If an attacker uploads an HTML file labelled image/png and you store it with that label and a public ACL, the browser renders it — as a page on your bucket's origin, with whatever scripts it contains. That is stored cross-site scripting, delivered by your own upload form. Sniffing the bytes, forcing attachment on anything executable and defaulting to a private ACL each close a different half of that door.

Which types are accepted, inline, or refused

Type Allowed by default Served inline Notes
JPEG, PNG, WebP Also the only formats that can be compressed.
GIF, AVIF Stored untouched — never transcoded.
PDF Downloaded rather than rendered.
HTML, SVG, XML, JavaScript Active content. Even if you allowlist it, it is always attachment.
MP4, HEIC, ZIP, MP3 Recognised, but you must allowlist them explicitly.
Anything unrecognised 400. "Unknown" is not treated as "probably fine".

Upload options

Everything about files lives in three top-level keys plus the upload object.

createRouterS3upload({
  model: Document,

  path: 'documents/',                                  // key prefix
  fields: [
    { name: 'file',  maxCount: 1 },
    { name: 'pages', maxCount: 10, formatToUrlObject: true },
  ],
  imgOptimizations: 'high',

  upload: {
    acl: 'public-read',                                // opt in deliberately
    allowedMimeTypes: ['image/jpeg', 'image/png', 'application/pdf'],
    maxFileSize: 5 * 1024 * 1024,                      // 5 MB per file
    maxFiles: 5,
    allowClientImageOptions: false,
  },
});
Option Default What it controls
path 'files/' Key prefix inside the bucket. Normalised so it cannot escape upwards with ../.
fields [{ name: 'file', maxCount: 1 }] Which multipart fields carry files, how many each accepts, and whether the saved value is a string or { url }.
imgOptimizations off 'low', 'medium' (or 'med') or 'high'.
upload.acl 'private' S3 object ACL. 'public-read' makes the returned URL work in a browser — and works for everyone else too.
upload.allowedMimeTypes JPEG, PNG, GIF, WebP, AVIF, PDF Compared against the detected type, never the client's header.
upload.maxFileSize 10 MB Per file, in bytes. Exceeding it is a 400 from Multer.
upload.maxFiles 10 Total files per request.
upload.allowClientImageOptions false Whether a client may pick the compression level. See the warning below.

Private files aren't reachable from a browser. With the default ACL, the URL in your database returns 403 to the public. That is intended — serve those objects through a presigned URL or a CDN with origin access. Each uploaded file also carries file.key alongside file.location, which is what a presigner needs.

Filenames are not preserved. Every object is stored as <timestamp>-<random> plus an extension derived from its bytes. A user-supplied name is untrusted input in a path, and two users uploading photo.jpg must not collide. Keep the original name in a database column if you need to show it.

Image optimization

Shrink images on the way to S3, without picking quality numbers yourself.

Set a level and the library re-encodes the image toward a target size, binary-searching encoder quality (bounded between 25 and 95, at most seven attempts) until it lands in range. You get predictable output sizes instead of a fixed quality that ruins some photos and barely touches others.

Level Target result Good for
'low' About 75–80% of the original size Photography, product shots — barely visible change.
'medium' / 'med' About 60–65% The usual choice for user avatars and content images.
'high' Under 1 MB: about 40–45%. Over 1 MB: a size target that scales from ~400–450 KB at 1 MB to ~600–700 KB at 5 MB. Phone camera uploads, where originals are many megabytes.
// On a router
createRouterS3upload({ model: Post, imgOptimizations: 'high' });

// Or directly, on a buffer you already have
import { compressImage } from 'express-controller-sets';
const smaller = await compressImage(buffer, 'image/jpeg', 'medium');

What is and isn't touched

JPEG · PNG · WebP re-encoded in the same format
GIF · AVIF · PDF uploaded exactly as received
sharp missing or failing warning logged, original uploaded

Why GIFs are left alone. An earlier version pushed every image through the JPEG encoder. A GIF came out as JPEG bytes stored under a .gif key with Content-Type: image/gif — animation gone, file corrupt, and nothing in the logs. Formats that cannot be re-encoded in place are now passed straight through, and the stored extension is re-derived from whatever bytes actually get uploaded.

allowClientImageOptions is off for a reason. The level drives up to seven sequential re-encodes of a multi-megabyte buffer. Let clients choose it and any upload endpoint becomes CPU amplification: a handful of requests asking for high can saturate your server. If you turn it on, the accepted values are still allowlisted — read from ?imgOptimizations=, the body, or the x-img-optimizations header.

S3 environment

Five variables. Only needed if you use an upload router.

S3_ENDPOINT=https://nyc3.digitaloceanspaces.com   # required
S3_SPACES_KEY=your-access-key                     # required
S3_SPACES_SECRET=your-secret-key                  # required
S3_BUCKET_NAME=your-bucket                        # required
S3_REGION=us-east-1                               # optional, defaults to us-east-1

The variable names carry "SPACES" because the client is configured with path-style addressing, which works with DigitalOcean Spaces, MinIO, Cloudflare R2 and Amazon S3 alike. Any S3-compatible endpoint will do.

This library does not call dotenv.config(). Loading environment is your application's decision, not a side effect a dependency imposes. Load it yourself before you start handling requests:

import 'dotenv/config';   // first import in your entry file

Configuration is read per request, not at import. An app that loads its environment after importing the package still works — no permanent 503 caused by a snapshot taken a moment too early. Missing variables produce a 503 and a server log naming exactly which ones are absent.

Auth & middlewares

The library builds the routes. Deciding who may call them is entirely yours.

createRouter({
  model: Order,
  middlewares: [requireAuth, requireRole('staff')],   // run before every route
  allowedFields: ['status', 'note'],
});

Everything in middlewares is applied with router.use(), so it guards all five endpoints in that router. When different verbs need different rules, use two routers on the same path:

// Public: anyone may read the catalogue
app.use('/api/products', createRouter({
  model: Product,
  query: ['category'],
  allowedFields: [],                       // no writes get through here
}));

// Private: only staff may change it
app.use('/api/admin/products', createRouter({
  model: Product,
  middlewares: [requireAuth, requireRole('staff')],
  query: ['category'],
  allowedFields: ['name', 'price', 'category', 'isFeatured'],
}));

Per-user data ("show me only my orders") needs your own middleware. The library has no concept of ownership. Constrain the query yourself before it reaches the controller, and remember that a client can still ask for any ID on /:id unless you check there too.

// A tenancy guard: force every request to be scoped to the caller
const onlyMine = (req, res, next) => {
  req.query.owner = String(req.user._id);   // 'owner' must be in `query`
  next();
};

createRouter({
  model: Order,
  middlewares: [requireAuth, onlyMine],
  query: ['owner', 'status'],
});

That guard covers the list endpoint. GET /:id, PATCH /:id and DELETE /:id address a record directly, so add a middleware that loads the record and compares its owner — or keep those verbs on an admin-only router.

Before you go live

Nine checks. None of them takes more than a minute.

Set allowedFields

On every router. If the startup console is clean, you've done it.

Add authentication

Nothing is protected until you supply middlewares.

Keep the allowlists tight

Only fields you would happily publish belong in filterableFields and sortableFields.

Check maxLimit

100 by default. Raise it only as far as your slowest collection tolerates.

Leave allowRawRegex off

Unless the endpoint is authenticated and the callers are trusted.

Mount errorHandler

Last, after every route. Without it, stack traces can escape.

Review upload settings

ACL, MIME allowlist and size limits — decide each one deliberately.

Never commit .env

Once a key is in git history, rotating it is the only fix.

Don't ship legacyMode

It re-opens every default this release closed. It's a bridge, not a destination.

A two-minute self-test. Against a staging copy, try each of these. Every one should fail:

# write a field you never allowlisted
curl -X POST …/api/users -H 'Content-Type: application/json' \
     -d '{"name":"x","role":"admin"}'          # role must not persist

# sort by something private
curl '…/api/users?sort=passwordHash'           # expect 400

# read the whole collection
curl '…/api/users' | jq '.data | length'       # expect ≤ maxLimit

# upload HTML disguised as an image
curl -F 'avatar=@evil.html;type=image/png' …   # expect 400

Upgrading from 2.x

3.0 is a security release. Every breaking change is a default that used to be unsafe.

If you run 2.x on a public endpoint, treat this as urgent. In 2.x a client could write any schema field, filter and sort on fields your API never returned, hang the database with a crafted search term, read entire collections in one request, and upload world-readable HTML to your bucket.

The fastest path

createRouter({ model: Product, legacyMode: true });

That restores 2.x behaviour so you can deploy today — then remove it one option at a time. legacyMode re-enables every vulnerability below and is scheduled for removal in 4.0. The full guide is in MIGRATION.md.

What changed What to do
Request bodies are filtered Add allowedFields or blockedFields to every router.
?compareField=, ?rangeField=, ?sort= are allowlisted List them in filterableFields / sortableFields, or rely on the query fallback.
Search terms are escaped and length-capped Nothing, unless clients sent real patterns — then allowRawRegex: true, behind auth.
Unpaginated reads capped at 100 Paginate, or raise maxLimit deliberately.
Relational search resolves the full nested path Re-check results for depth-2 paths — they were querying the wrong field before.
String-form search now reads ?s= Update clients that used ?title= for search.
Uploads default to private, content-sniffed Set upload.acl and upload.allowedMimeTypes if you need the old behaviour. Audit existing objects separately — they keep their old ACL.
Clients no longer choose the compression level upload.allowClientImageOptions: true to opt back in.
GIF and SVG are no longer transcoded Nothing — previously they were being corrupted.
5xx responses no longer echo internals; duplicates are 409 Update any client that parsed 500 message text.
Reads are no longer .lean() by default Your toJSON transforms now apply. Set lean: true to restore 2.x speed — and check what those transforms were hiding.
No import-time dotenv.config(); multer-s3 removed Load your own environment; uninstall multer-s3.

The lean change is the one most likely to alter your responses. 2.x used .lean() on list endpoints, which bypasses schema.set('toJSON', { transform }). If you stripped sensitive fields there, 2.x was leaking them on every list request and 3.0 stops. Compare a list response before and after upgrading.

Constructor form

// Still supported, deprecated
new ControllerSets(Model, '-createdAt', ['category'], ['name']);

// Preferred — and required for any 3.0 option
new ControllerSets({ model: Model, orderBy: '-createdAt', query: ['category'] });

Every option

The complete list, with defaults.

Controller options

Accepted by createRouter, createRouterS3upload and the ControllerSets constructor alike.

Option Type Default Description
model Model required The Mongoose model these endpoints operate on.
orderBy string 'none' Default sort. Prefix with - for descending. Never allowlist-checked — it's your choice.
query string[] [] Fields exposed as equality filters, and the fallback allowlist for filtering and sorting.
search string | string[] [] Fields searched by ?s=. Dot-notation reaches into ref'd models.
allowedFields string[] | {create, update} unset Fields a client may write. Unset means every schema field is writable, and logs a warning.
blockedFields string[] | {create, update} unset Fields a client may never write. Applied after allowedFields.
filterableFields string[] query Fields usable with ?compareField= and ?rangeField=.
sortableFields string[] query + orderBy Fields usable with ?sort=.
maxLimit number 100 Hard cap on documents returned, paginated or not. Also caps ?pageSize=.
maxSearchLength number 128 Longest accepted search term. Longer terms return 400.
allowRawRegex boolean false Pass search terms to MongoDB unescaped. Unsafe for untrusted callers.
lean boolean false Return plain objects. Faster, but skips schema toJSON transforms.
onGet function 'none' Per-request populates and selects. Applies to reads and to the document returned by PATCH.
runAfterCreate function 'none' Called with the created document, awaited before the 201.
strictAfterCreate boolean false Propagate runAfterCreate failures instead of logging them.
logger {warn, error, debug?} console Where warnings and hook failures go. Point it at your own logger.
legacyMode boolean false Restore 2.x behaviour: no field gating, raw regex, unbounded reads. Temporary.

Router-only options

Option Where Default Description
middlewares both factories [] Express middlewares applied to every route in the router.
path S3 router 'files/' Key prefix inside the bucket.
fields S3 router [{name:'file',maxCount:1}] Multipart file fields.
imgOptimizations S3 router off 'low' | 'medium' | 'med' | 'high'.
upload S3 router {} ACL, MIME allowlist and limits — see Upload options.

Exports

Export Type Purpose
createRouter(options) function Five CRUD endpoints for a model.
createRouterS3upload(options) function The same, with S3 uploads on POST and PATCH.
ControllerSets class The handlers themselves, for your own routes.
errorHandler middleware Consistent JSON errors. Mount last.
fileUploadMiddleware(req,res,next,options) middleware Uploads without the CRUD routes.
compressImage(buffer,type,level) async Re-encode an image buffer toward a target size.
HttpError class An error carrying a status whose message is safe to return.
escapeRegex(value) function Escape regex metacharacters in your own queries.

Every query parameter

Everything GET / understands.

Parameter Example Notes
page ?page=2 Switches on the pagination block. Starts at 1; junk values become 1.
pageSize ?pageSize=20 Default 50, clamped to maxLimit.
s / search ?s=laptop Case-insensitive, matched literally, across the search fields. Max 128 characters.
sort ?sort=-price Must be in sortableFields, else 400. - means descending.
any field named in query ?category=65af… Equality filter. Repeat the parameter for an $in match.
rangeField + range ?rangeField=price&range=10-100 Inclusive bounds; either side may be omitted. Field must be in filterableFields.
compareField + compareValue ?compareField=price&compareValue=50 Field must be in filterableFields. Numeric-looking values become numbers.
compareOperator ?compareOperator=gt gt, gte, lt, lte, ne, eq. Defaults to eq.

Putting several together

# featured laptops between 500 and 1500, cheapest first, second page of 20
GET /api/products
      ?isFeatured=true
      &s=laptop
      &rangeField=price&range=500-1500
      &sort=price
      &page=2&pageSize=20

Filters combine with AND. The search term is the one exception: it becomes an OR across your search fields, and that whole group is ANDed with everything else.

Response shapes

Four shapes, and success is always there to branch on.

// GET /api/products
{
  "success": true,
  "data": [ { "_id": "65af…", "name": "Laptop", "price": 1299 } ]
}
// GET /api/products?page=2&pageSize=20
{
  "success": true,
  "data": [ /* … */ ],
  "pagination": {
    "currentPage": 2,
    "pageSize": 20,
    "totalPages": 7,
    "totalRecords": 132
  }
}
// GET /api/products/65af…   ·   POST /api/products   ·   PATCH /api/products/65af…
{
  "success": true,
  "data": { "_id": "65af…", "name": "Laptop", "price": 1299 }
}
// DELETE /api/products/65af…
{ "success": true, "message": "Item successfully deleted." }

// Any failure
{ "success": false, "error": "Entry not found.", "requestId": "0f2a…" }

requestId appears on errors that pass through errorHandler. Errors answered directly by the controller carry success and error only.

const res  = await fetch('/api/products?page=1');
const body = await res.json();

if (!body.success) throw new Error(body.error);
body.data;                    // always an array here
body.pagination.totalPages;   // present because ?page was sent

TypeScript

Type declarations ship with the package. Nothing to install.

import express from 'express';
import { createRouter, errorHandler, type RouterOptions } from 'express-controller-sets';
import Product, { type IProduct } from './models/Product.js';

const options: RouterOptions<IProduct> = {
  model: Product,
  orderBy: '-createdAt',
  query: ['category'],
  allowedFields: ['name', 'price'],
  onGet: async (req, res) => ({ populates: 'category', selects: 'name price' }),
};

const app = express();
app.use('/api/products', createRouter(options));
app.use(errorHandler);

Exported types you'll reach for: ControllerOptions, RouterOptions, RouterS3Options, UploadOptions, UploadField, OnGetFn, OnGetResult, FieldPolicy, ImageOptimizationLevel and Logger.

Types don't validate field names. allowedFields is string[], so a typo — 'pirce' — compiles happily and silently drops the real field at runtime. If a field refuses to save, check the spelling in your allowlist first.

Custom routes

When the five generated endpoints aren't quite the shape you need.

Use the handlers on your own router

Every controller method is a plain Express handler, so you can mount them individually — different middleware per verb, extra routes alongside, whatever you like.

import express from 'express';
import { ControllerSets } from 'express-controller-sets';
import Product from './models/Product.js';

const controller = new ControllerSets({
  model: Product,
  query: ['category'],
  allowedFields: ['name', 'price'],
});

const router = express.Router();

router.get('/',      controller.getAll);                    // public
router.get('/:id',   controller.getById);                   // public
router.post('/',     requireAuth, controller.create);       // staff only
router.patch('/:id', requireAuth, controller.update);
router.delete('/:id', requireAuth, requireAdmin, controller.delete);

// …and add whatever the generated set doesn't cover
router.get('/:id/related', myOwnHandler);

export default router;

Add routes to a generated router

A generated router is an ordinary Express router. Mount your own routes on the same path — declare the more specific ones first so they aren't captured by /:id.

const extras = express.Router();
extras.get('/featured', listFeatured);      // would otherwise hit /:id

app.use('/api/products', extras);
app.use('/api/products', createRouter({ model: Product }));

Uploads without CRUD

import { fileUploadMiddleware } from 'express-controller-sets';

const upload = (req, res, next) => fileUploadMiddleware(req, res, next, {
  uploadPath: 'invoices/',
  fields: [{ name: 'invoice', maxCount: 1 }],
  allowedMimeTypes: ['application/pdf'],
  maxFileSize: 2 * 1024 * 1024,
});

app.post('/api/invoices', requireAuth, upload, (req, res) => {
  res.json({ url: req.body.invoice });   // already uploaded and validated
});

Common questions

The things people hit in the first hour.

My console shows a warning about allowedFields. What is it?

It means that model's routes will write any field a client sends, including ones like role or isAdmin. Add allowedFields (or blockedFields) and it goes away. It prints once per model per process, not per request.

My POST returns 400 "Request body contains no writable fields."

Everything you sent was filtered out. Usual causes: the field names aren't in allowedFields, you sent only immutable fields like _id, or you forgot app.use(express.json()) so the body was never parsed at all. On an upload router, also check the multipart field name matches your fields entry.

Why does ?sort=name return 400?

name isn't in sortableFields. If you never set that option, it defaults to your query array plus whatever orderBy names — which usually doesn't include name. Add it explicitly.

Why do I only get 100 records back?

maxLimit. Unpaginated reads are capped so one request can't pull an entire collection into memory. Use ?page=, or raise the cap with maxLimit: 500 if you know the collection stays small.

Does this handle authentication?

No, and deliberately so. Every generated endpoint is public until you pass your own guards in middlewares. See Auth & middlewares, including the pattern for scoping records to the logged-in user.

The uploaded file URL returns 403 in the browser.

Uploads default to a private ACL, so the URL isn't publicly fetchable. Either serve the object through a presigned URL or a CDN with origin access (use file.key), or opt in with upload: { acl: 'public-read' } knowing it's readable by anyone with the link.

My SVG upload is rejected.

SVG is active content — it can carry scripts — so it isn't on the default MIME allowlist. You can add it to allowedMimeTypes, but it will still be stored Content-Disposition: attachment so a browser downloads rather than renders it. For icons, converting to PNG or WebP before upload is usually the better answer.

Do I need sharp and the AWS SDK if I only use CRUD?

No. They're optional and imported lazily — nothing loads until an upload router actually handles a file. A CRUD-only app never pays for the native binary or the SDK.

Can I use this from CommonJS (require)?

Not directly — the package is ESM only. In a CommonJS project use a dynamic import: const cs = await import('express-controller-sets').

Is PUT supported?

No. Updates are PATCH only and use $set, so omitted fields are left alone rather than wiped. If a client must send PUT, add your own route pointing at controller.update.

Can one router serve two models, or two routers one model?

One router, one model. Two routers on one model is not only allowed but recommended — a public read-only one and a guarded admin one, each with its own allowlists. See Auth & middlewares.

How do I filter on a field the client shouldn't choose?

Set it in a middleware before the controller runs — write to req.query and include the field in query. The client can send its own value, but yours overwrites it because your middleware runs last.

Why did my search return nothing when the term clearly exists?

Two likely causes. If you searched a relational field like category.name, the first segment must be a real ref in your schema — otherwise that clause is dropped, and if every clause drops the result is deliberately empty. Second, terms are matched literally, so regex syntax in the term matches nothing rather than acting as a pattern.

Is legacyMode safe to leave on?

No. It disables field gating, restores raw regex and removes the read cap — every vulnerability 3.0 fixed. It exists so you can deploy an upgrade today and tighten options one at a time, and it's scheduled for removal in 4.0.