@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 +29 -4
- package/README.md +47 -2
- package/crudify.js +27 -23
- package/package.json +1 -1
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
|
-
## [
|
|
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
|
|
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 →
|
|
116
|
+
## Migration guide: 1.x → 3.0
|
|
92
117
|
|
|
93
|
-
| In 1.x | In
|
|
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` (
|
|
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 ?? {},
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
for (const field in
|
|
199
|
-
const v =
|
|
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 (!
|
|
202
|
-
data[field] = v
|
|
204
|
+
if (!lists[field].some(x => String(x) === String(v))) throw forbidden()
|
|
203
205
|
}
|
|
204
|
-
|
|
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
|
|
227
|
-
|
|
228
|
-
|
|
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