# Migrating from 2.x to 3.0

3.0 is a security release. It fixes vulnerabilities that let a client read fields your API
never exposed, write fields it was never meant to write, and host arbitrary HTML on your S3
bucket. **Every one of those fixes is a breaking change**, because in 2.x the insecure
behaviour was the default.

If you run this package on a public endpoint, treat this as an urgent upgrade.

---

## The fastest path

Add `legacyMode: true` to restore 2.x behaviour, deploy, then remove it option by option:

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

This is a bridge, not a destination. It re-enables every vulnerability listed below and will
be removed in 4.0.

---

## What changed, and what to do

### 1. Request bodies are filtered (`allowedFields` / `blockedFields`)

**Was:** the entire request body went to `Model.create()` and `$set`. Any schema field was
client-writable, including `role`, `isAdmin`, and `balance`.

**Now:** `_id`, `__v`, `createdAt`, `updatedAt`, `$`-prefixed keys, and dotted keys are always
stripped. Anything else is still writable **until you configure a policy** — so this alone does
not close the hole. Configure it:

```js
createRouter({
    model: User,
    allowedFields: ["name", "email", "avatar"],           // both verbs
    // or per verb:
    // allowedFields: { create: ["name", "email"], update: ["name"] },
    // or subtract instead:
    // blockedFields: ["role", "isAdmin"],
});
```

Until you do, the package logs a warning at startup naming the model. A request whose body
contains no writable field now returns **400** instead of silently creating an empty record.

### 2. `?compareField=`, `?rangeField=` and `?sort=` are allowlisted

**Was:** these accepted any field name, bypassing `query` entirely. A client could filter on
`passwordResetToken` and binary-search its value from the result counts.

**Now:** they resolve against `query` by default. Unknown fields return **400**.

```js
createRouter({
    model: Product,
    query: ["category"],                        // equality filters + default allowlist
    filterableFields: ["category", "price"],    // ?compareField= / ?rangeField=
    sortableFields: ["price", "createdAt"],     // ?sort=
});
```

Your configured `orderBy` is always permitted — only client-supplied `?sort=` is checked.

### 3. Search terms are escaped

**Was:** `?s=` went to `$regex` raw, so `?s=(a+)+$` caused catastrophic backtracking inside
mongod.

**Now:** escaped and matched literally; terms over 128 characters return 400
(`maxSearchLength`). If you relied on clients passing real patterns:

```js
createRouter({ model: Post, search: ["title"], allowRawRegex: true });
```

Only do this behind authentication.

### 4. Reads are always bounded

**Was:** `GET /items` without `?page` returned the entire collection.

**Now:** capped at `maxLimit` (default 100), paginated or not. If you depended on unbounded
reads, raise `maxLimit` explicitly or paginate.

### 5. Relational search targets the full path

**Was:** `search: ["author.profile.name"]` silently searched `profile`, not `profile.name`.

**Now:** the full nested path is used. Results will change where you used depth-2 paths —
they were previously querying the wrong field.

### 6. `?s=` is the only search parameter

**Was:** array-form `search` read `?s`/`?search`, but string-form `search: "title"` read
`?title`. Two different contracts on one option.

**Now:** both use `?s=` / `?search=`.

### 7. Uploads are private and content-sniffed

**Was:** `ACL: "public-read"` was hardcoded, `Content-Type` came from the client's multipart
header, and there was no type validation. Uploading HTML labelled `image/png` produced a
world-readable page executing on your bucket's origin.

**Now:**

- ACL defaults to **`private`**
- the stored `Content-Type` and file extension come from the **bytes**, not the client
- only `image/jpeg|png|gif|webp|avif` and `application/pdf` are accepted
- anything not on a strict inline allowlist is stored `Content-Disposition: attachment`

```js
createRouterS3upload({
    model: Document,
    path: "docs/",
    upload: {
        acl: "public-read",                    // opt back in deliberately
        allowedMimeTypes: ["image/jpeg", "image/png", "application/pdf"],
        maxFileSize: 5 * 1024 * 1024,
        maxFiles: 5,
    },
});
```

> **Existing objects are unaffected.** Files uploaded under 2.x keep their `public-read` ACL.
> Audit your bucket separately — this change only governs new uploads.

If you switch to `private`, the returned URL is no longer publicly fetchable. Serve those
objects through presigned URLs or a CDN origin-access identity. `file.key` is populated
alongside `file.location` for this purpose.

### 8. Clients no longer choose image optimization

**Was:** read from query, body, and two headers, overriding your server config. Forcing
`high` on every upload is CPU amplification.

**Now:** server config only. To opt back in: `upload: { allowClientImageOptions: true }` —
values are allowlisted even then.

### 9. Non-optimizable formats pass through untouched

**Was:** GIF and SVG fell through to the JPEG encoder, producing JPEG bytes stored under a
`.gif` key with `Content-Type: image/gif`. Animation was lost and the object was corrupt.

**Now:** only JPEG, PNG and WebP are re-encoded; everything else is uploaded as-is, and the
extension is re-derived from the output bytes.

### 10. 5xx responses no longer echo internal messages

**Was:** `err.message` was returned verbatim, leaking connection strings, hostnames, and
bucket names.

**Now:** any unclassified 5xx returns `"Internal Server Error"` plus a `requestId` that
correlates to the server log. Deliberate 4xx messages are unchanged, and duplicate-key errors
now return **409** instead of 500.

### 11. `dotenv` is no longer called, and `multer-s3` was never used

The package no longer calls `dotenv.config()` — loading environment is your application's
job. Ensure you load it yourself before handling requests. S3 config is now read **per
request**, so env vars loaded after import work correctly.

`multer-s3` was listed as a peer dependency but never imported; it has been removed. You can
uninstall it. `sharp` is now an optional dependency and is loaded lazily, so CRUD-only
consumers no longer pay for a native binary.

---

## Behavioural changes worth checking

| Change | Impact |
|---|---|
| `update`/`delete` are a single atomic query | A missing record still returns 404; the previous read-then-write race is gone |
| `runValidators` now passes `context: "query"` | Custom update validators using `this` behave correctly |
| Reads are no longer `.lean()` by default | Schema `toJSON` transforms now apply — **if you strip fields there, they are now correctly stripped**. Set `lean: true` to restore 2.x behaviour and its performance |
| `runAfterCreate` failures | Still logged and swallowed; set `strictAfterCreate: true` to propagate |

The `lean` change is the one most likely to alter your response bodies. In 2.x, `getAll` used
`.lean()`, which bypasses `schema.set("toJSON", { transform })`. If you relied on such a
transform to remove sensitive fields, **2.x was leaking them on list endpoints** and 3.0
stops doing so.

---

## Constructor form

The positional signature still works but is deprecated:

```js
// still supported
new ControllerSets(Model, "-createdAt", ["category"], ["name"]);

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