@aaroncadillac/crudify-mongo 3.0.0 → 3.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,7 +2,32 @@
2
2
 
3
3
  All notable changes to this project are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project adheres to [Semantic Versioning](https://semver.org/).
4
4
 
5
- ## [2.0.0] - 2026-10-21
5
+ ## [Unreleased]
6
+
7
+ ### ⚠ BREAKING
8
+
9
+ - **`additionalRoutes` was removed** (deprecated since the 2.x line, both the function and the array form). Passing it now throws a `TypeError` at registration pointing to the replacement. Migrate to `routes(api, ctx)` or `actions`. Because this is a breaking change on top of the published 3.0.0, the next release that includes it must be a **major** version.
10
+
11
+ ### Fixed
12
+
13
+ - **Bulk `POST` ignored schema defaults when validating.** The array form validated each item with the static `Model.validate(data)`, which does not apply `default`s, so a bulk without a field that has a `default` (and is `required`, e.g. `status`) answered `400` while the same document sent alone worked. Each item is now built once with `new Model(data)`, validated with `doc.validate()` (defaults applied) and the same documents are passed to `Model.create(docs, { validateBeforeSave: false })` (or `insertMany`), so nothing is built or validated twice. Everything is still validated before writing (an invalid item inserts nothing), save hooks run once per document, and `createScope` checks are untouched. Release as a patch (`[patch]`).
14
+
15
+ ## [3.1.0] - 2026-10-22
16
+
17
+ ### Added
18
+
19
+ - **`createScope: 'strict' | 'after-hooks'`** (default `'strict'`, exactly the 3.0.0 behaviour). With `'after-hooks'`, `beforeCreate` can derive a scope key the client does not send (for example an order's `restaurantId` from its `tableIds`): the key is no longer demanded before the hook, the **final document** is validated afterwards (`400 <field> is required` when missing, `403` when outside the scope's `{ $in }` list) and the equality keys are forced last. Bulk requests are validated entirely before anything is inserted. Invalid values throw at registration. See "Deriving a scope key" in the README.
20
+
21
+ ### Security
22
+
23
+ - The document that reaches Mongo is always checked against the scope on create, in both modes. In `'after-hooks'` a hook can derive a key but cannot move a document out of its scope, nor overwrite a forced key.
24
+
25
+ ### Notes
26
+
27
+ - In `'after-hooks'` mode `beforeCreate` receives the document without the forced equality keys (they are applied after the hook); in `'strict'` mode it still receives them, as in 3.0.0.
28
+ - Publishing: the release workflow bumps the version from the commit message, so use `[minor]` for this release.
29
+
30
+ ## [3.0.0] - 2026-10-21
6
31
 
7
32
  Crudify is now **secure by default** and extensible. The plugin that used to hand raw client input to Mongo (and made every real application re-implement it) now sanitizes queries and bodies, bounds results, hides internals from error responses and offers `scope` / `authorize` / hooks / custom routes as first-class seams. It stays small: two files, no runtime dependencies. It does **not** authenticate or authorize by itself; it gives you the points where your tool of choice (`@fastify/auth`, Casbin, JWT...) plugs in.
8
33
 
@@ -38,7 +63,7 @@ Crudify is now **secure by default** and extensible. The plugin that used to han
38
63
 
39
64
  ### Deprecated
40
65
 
41
- - `additionalRoutes` (both the function form and the array form the old README described). Use `routes` or `actions`. It still works and logs a warning; it will be removed in 3.0.
66
+ - `additionalRoutes` (both the function form and the array form the old README described). Use `routes` or `actions`. It still works and logs a warning; it will be removed in 4.0.
42
67
 
43
68
  ### Fixed
44
69
 
@@ -88,9 +113,9 @@ Each audited problem has a test; the suites live in `test/unit` (model stubbed,
88
113
 
89
114
  Needs of the first real consumer (a multi-tenant restaurant API) are covered too: tenant isolation, authorize by verb, operator allowlist, limits, writable / readonly fields, tenant forcing on create, `methods`, consistent errors, bulk, custom routes and actions with shared scope (`integration` "custom routes and actions share scope + authorize").
90
115
 
91
- ## Migration guide: 1.x → 2.0
116
+ ## Migration guide: 1.x → 3.0
92
117
 
93
- | In 1.x | In 2.0 | If you need the old behaviour |
118
+ | In 1.x | In 3.0 | If you need the old behaviour |
94
119
  |---|---|---|
95
120
  | `GET /` returned everything | 50 per page, max 100 | `query: { defaultLimit, maxLimit }`; there is no "all rows" mode, page through the results |
96
121
  | Any Mongo operator in `filters` | Allowlisted operators | `query: { operators: [...DEFAULT_OPERATORS, '$size', '$all', '$nor'] }`, or `sanitizer: false` (not recommended) |
package/README.md CHANGED
@@ -75,7 +75,7 @@ Without any configuration:
75
75
  - `options` only honour `page`, `limit` and `sort`. `limit` defaults to 50 and is capped at 100; `populate`, `select`, `lean` and `pagination: false` from the client are ignored.
76
76
  - Writes drop `_id`, `__v` and the timestamps, accept no update operators besides `$set` / `$unset`, and refuse operators nested inside values.
77
77
  - Fields with `select: false` in your schema cannot be filtered or sorted on (it would be a search oracle).
78
- - Bulk creation goes through `Model.create`, so `pre('save')` / `post('save')` hooks always run, and an invalid item inserts nothing.
78
+ - Bulk creation goes through `Model.create`, so `pre('save')` / `post('save')` hooks always run, schema defaults are applied before validating, and an invalid item inserts nothing.
79
79
  - Errors are mapped to a fixed shape. Duplicate keys become a generic `409`; unexpected errors become `500 Internal error` (the detail goes to your logs, never to the client).
80
80
  - Invalid ids answer `404` without touching the database.
81
81
 
@@ -138,6 +138,7 @@ The default limits (all configurable through `query`) are depth 4, 100 keys, 100
138
138
  | `routes` | none | `(api, ctx) => void`: add your own routes |
139
139
  | `actions` | none | Non-CRUD operations on one document, see below |
140
140
  | `strict` | `false` | Throw at startup if `scope` is missing or the sanitizer is disabled |
141
+ | `createScope` | `'strict'` | When the scope's `{ $in }` keys are checked on create: `'strict'` (before `beforeCreate`) or `'after-hooks'` (on the final document), see "Deriving a scope key" |
141
142
 
142
143
  `query`: `operators`, `maxDepth`, `maxKeys`, `maxArray`, `maxBranches`, `search` (`'contains' | 'prefix' | false`), `maxSearchLength`, `filterable` (allowlist of root fields), `defaultLimit` (50), `maxLimit` (100), `maxSort`, `populate` (paths a client may populate).
143
144
 
@@ -186,6 +187,50 @@ app.register(Crudify, {
186
187
  - `scope` must return a plain object. Anything else is a `403` (fail closed). Return `{}` to say "no restriction" explicitly, or pass `scope: false`.
187
188
  - `authorize` runs first, before any query. Use it for role checks that don't depend on the document; use `scope` for anything about *which* documents (it is atomic: no read-then-check race).
188
189
 
190
+ ### Deriving a scope key in `beforeCreate` (`createScope`)
191
+
192
+ Sometimes the client does not send the scope key because it is *derived*: an order carries `tableIds`, and its `restaurantId` comes from those tables. By default (`createScope: 'strict'`, the behaviour of 3.0.0) Crudify demands the `{ $in }` key **before** `beforeCreate` runs, so such a resource answers `400 restaurantId is required`.
193
+
194
+ With `createScope: 'after-hooks'` the create pipeline becomes:
195
+
196
+ 1. sanitize the client document (scope keys the client sent are kept, forced equality keys are dropped);
197
+ 2. run `beforeCreate(data, req)`, which can derive or stamp the key;
198
+ 3. **validate the final document**: every `{ $in }` key must exist (`400 <field> is required`) and be in the list (`403 Forbidden`);
199
+ 4. impose the equality keys last, so the hook cannot overwrite them.
200
+
201
+ ```js
202
+ app.register(Crudify, {
203
+ url: '/orders',
204
+ Model: Order,
205
+ createScope: 'after-hooks',
206
+ scope: async (req) => ({
207
+ accountId: req.user.accountId,
208
+ restaurantId: { $in: await allowedRestaurantIds(req.user) } // e.g. from your permission system
209
+ }),
210
+ hooks: {
211
+ // `data` has no accountId yet: it is forced after this hook
212
+ beforeCreate: async (data) => {
213
+ const tables = await Table.find({ _id: { $in: data.tableIds ?? [] } }).lean()
214
+ const restaurants = new Set(tables.map(t => String(t.restaurantId)))
215
+ if (tables.length !== data.tableIds?.length || restaurants.size !== 1) {
216
+ throw new CrudifyError(400, 'tableIds must belong to a single restaurant')
217
+ }
218
+ return { ...data, restaurantId: [...restaurants][0] }
219
+ }
220
+ }
221
+ })
222
+ ```
223
+
224
+ What stays guaranteed: a hook can derive the key but **never move a document out of its scope**, because Crudify checks the result and forces the equality keys itself. Bulk requests are validated entirely before anything is written. `list`, `get`, `update` and `delete` are not affected.
225
+
226
+ What you take on by choosing it:
227
+
228
+ - `beforeCreate` receives the document **without the forced keys** (`accountId` above is not there yet). Don't rely on them inside the hook.
229
+ - The hook must derive the key from trusted data (look it up in your database; never copy it from another client-supplied field without checking).
230
+ - A hook that derives nothing is a `400`, and one that derives a value outside the caller's list is a `403`.
231
+
232
+ Not recommended: setting `scope: {}` for the action and validating the tenant yourself inside `beforeCreate`. It works, but then nothing is checked by Crudify and a forgotten check is a cross-tenant leak; that risk is entirely yours.
233
+
189
234
  ## Hooks and request order
190
235
 
191
236
  ```js
@@ -252,7 +297,7 @@ app.register(Crudify, {
252
297
 
253
298
  `ctx` has `Model`, `url`, `scope(req, action)`, `where(req, action, base)` (a filter with the scope applied), `load(req, action, id)` (a real Mongoose document, or 404), `list(req)` (sanitized + scoped paginate), `sanitize.{filter, options, update, create, query}`, `serialize`, `notFound` and `checkId`.
254
299
 
255
- > `additionalRoutes` (a function, or an array of `{ method, url, handler }`) still works but is deprecated and will be removed in 3.0. Use `routes` or `actions`.
300
+ > `additionalRoutes` (deprecated in 2.x) was removed: passing it throws at registration. Use `routes` or `actions`.
256
301
 
257
302
  ## Recipes
258
303
 
package/crudify.js CHANGED
@@ -124,6 +124,9 @@ const normalizeOptions = (opts, log) => {
124
124
  const unknown = methods.find(m => !VERBS.includes(m))
125
125
  if (unknown) throw new TypeError(`Crudify: unknown method "${unknown}"`)
126
126
  if (methods.includes('list') && typeof Model.paginate !== 'function') throw new TypeError('Crudify: `list` needs mongoose-paginate-v2 (Model.paginate)')
127
+ if (opts.additionalRoutes !== undefined) throw new TypeError('Crudify: `additionalRoutes` was removed, use `routes(api, ctx)` or `actions`')
128
+ const createScope = opts.createScope === undefined ? 'strict' : opts.createScope
129
+ if (createScope !== 'strict' && createScope !== 'after-hooks') throw new TypeError('Crudify: `createScope` must be "strict" or "after-hooks"')
127
130
  const hooks = opts.hooks ?? {}
128
131
  for (const [name, fn] of Object.entries({ scope, authorize, serialize, mapError, ...hooks })) {
129
132
  if (fn !== undefined && fn !== false && typeof fn !== 'function') throw new TypeError(`Crudify: \`${name}\` must be a function`)
@@ -147,9 +150,9 @@ const normalizeOptions = (opts, log) => {
147
150
  : { ...compiled, ...(isPlainObject(opts.sanitizer) && opts.sanitizer) }
148
151
 
149
152
  return {
150
- url, Model, methods, scope, authorize, serialize, mapError, hooks, bulk, san,
153
+ url, Model, methods, scope, authorize, serialize, mapError, hooks, bulk, san, lateScope: createScope === 'after-hooks',
151
154
  lean: !!opts.lean, select: opts.select, routeOptions: opts.routeOptions ?? {}, handlers: opts.handlers ?? {},
152
- routes: opts.routes, actions: opts.actions ?? {}, additionalRoutes: opts.additionalRoutes,
155
+ routes: opts.routes, actions: opts.actions ?? {},
153
156
  idIsObjectId: Model.schema.path('_id')?.instance === 'ObjectId',
154
157
  schemas: buildSchemas({ JSONSchema: opts.JSONSchema, readonly: new Set(readonly), schema: opts.schema, bulk })
155
158
  }
@@ -193,15 +196,25 @@ const createHandlers = (c, ctx) => {
193
196
  const { Model, hooks } = c
194
197
  const { serialize } = ctx
195
198
 
196
- const prepare = (item, rules) => {
197
- const data = ctx.sanitize.create(item, rules.keys)
198
- for (const field in rules.inLists) {
199
- const v = item[field]
199
+ // Scope keys of a new document: `{ $in: [...] }` values must contain the document's own, equality values are forced.
200
+ const checkLists = (data, lists) => {
201
+ for (const field in lists) {
202
+ const v = data[field]
200
203
  if (v === undefined) throw new CrudifyError(400, `${field} is required`, { code: 'validation_error' })
201
- if (!rules.inLists[field].some(x => String(x) === String(v))) throw forbidden()
202
- data[field] = v
204
+ if (!lists[field].some(x => String(x) === String(v))) throw forbidden()
203
205
  }
204
- return Object.assign(data, rules.forced)
206
+ }
207
+
208
+ // 'strict' checks the client's document before `beforeCreate`; 'after-hooks' lets the hook derive the keys and checks
209
+ // the FINAL document instead, forcing the equality keys last so a hook can never move a document out of its scope.
210
+ const prepare = async (item, req, rules) => {
211
+ const data = ctx.sanitize.create(item, rules.keys)
212
+ for (const field in rules.inLists) if (item[field] !== undefined) data[field] = item[field]
213
+ if (!c.lateScope) { checkLists(data, rules.inLists); Object.assign(data, rules.forced) }
214
+ const out = hooks.beforeCreate ? (await hooks.beforeCreate(data, req)) ?? data : data
215
+ if (!c.lateScope) return out
216
+ checkLists(out, rules.inLists)
217
+ return Object.assign(out, rules.forced)
205
218
  }
206
219
 
207
220
  return {
@@ -218,14 +231,12 @@ const createHandlers = (c, ctx) => {
218
231
  const rules = writeRules(req.crudify.filter)
219
232
  const isBulk = Array.isArray(req.body)
220
233
  const data = []
221
- for (const item of isBulk ? req.body : [req.body]) {
222
- const d = prepare(item, rules)
223
- data.push(hooks.beforeCreate ? (await hooks.beforeCreate(d, req)) ?? d : d)
224
- }
234
+ for (const item of isBulk ? req.body : [req.body]) data.push(await prepare(item, req, rules))
225
235
  let docs
226
- if (isBulk) { // validate everything first: an invalid item inserts nothing
227
- await Promise.all(data.map(d => Model.validate(d)))
228
- docs = await (c.bulk.insertMany ? Model.insertMany(data) : Model.create(data)) // create() runs save hooks
236
+ if (isBulk) { // build each document once (applies schema defaults) and validate all before writing: an invalid item inserts nothing
237
+ const built = data.map(d => new Model(d))
238
+ await Promise.all(built.map(d => d.validate()))
239
+ docs = await (c.bulk.insertMany ? Model.insertMany(built) : Model.create(built, { validateBeforeSave: false })) // create() runs save hooks; already validated
229
240
  } else docs = await Model.create(data[0])
230
241
  if (hooks.afterCreate) await hooks.afterCreate(docs, req)
231
242
  reply.code(201)
@@ -261,8 +272,6 @@ const createHandlers = (c, ctx) => {
261
272
  }
262
273
 
263
274
  // ── The plugin ───────────────────────────────────────────────────────────────
264
- let deprecationShown = false
265
-
266
275
  async function Crudify (fastify, opts) {
267
276
  const c = normalizeOptions(opts, fastify.log)
268
277
  const ctx = createContext(c)
@@ -322,11 +331,6 @@ async function Crudify (fastify, opts) {
322
331
  route(method.toUpperCase(), path, { ...rest, action, load }, handler)
323
332
  }
324
333
 
325
- if (c.additionalRoutes) { // deprecated, removed in 3.0
326
- if (!deprecationShown) { deprecationShown = true; fastify.log.warn('Crudify: `additionalRoutes` is deprecated, use `routes` or `actions`') }
327
- if (typeof c.additionalRoutes === 'function') c.additionalRoutes(fastify, opts)
328
- else for (const { method = 'GET', url, handler, ...rest } of c.additionalRoutes) route(method.toUpperCase(), url, rest, handler)
329
- }
330
334
  }
331
335
 
332
336
  Crudify[Symbol.for('plugin-meta')] = { name: 'crudify-mongo', fastify: '4.x || 5.x' }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aaroncadillac/crudify-mongo",
3
- "version": "3.0.0",
3
+ "version": "3.1.1",
4
4
  "description": "A secure-by-default CRUD generator for Fastify and Mongoose, with hooks, scoping and a built-in query sanitizer",
5
5
  "keywords": [
6
6
  "fastify",