# Upgrading

::: info What you'll learn
What changed between versions, and what to change in your app when you upgrade.

**Before this page:** [Configuration](./configuration).
:::

Upgrade every `@easy-cms/*` package and `easy-cms` together: they are released with the same
version number.

```sh [pm]
npm install @easy-cms/core@latest easy-cms@latest
```

Add the other `@easy-cms/*` packages your app uses (`@easy-cms/nuxt`, `@easy-cms/db-sqlite`, …)
to the same command.

Before 1.0, a minor version (0.48 → 0.60) may change names. Each change below says what to do.
When the config still uses an old name, the app doesn't start and the error names the new one:

```
Invalid Easy CMS config (1 problem):
  • admin.siteUrl: is now `siteURL`
```

## From 0.x to 1.0 {#to-1-0}

0.60 settled the names 1.0 keeps; the releases after it tightened security and readied the
experimental plugins. An app on 0.48 or older meets all of it at once. Changes that stop the app
say so, with the new name; the ones in [the checklist](#checklist) don't, so read it before you
deploy.

### What changed

- **Names** of config options, Local API methods, CLI commands and plugin options
  ([0.60](#v0-60)). A renamed config option stops startup and names the new one.
- **REST routes**: signing in moved to `<api>/auth/*`; the old `/users/*` paths, `POST` to a
  global and `?fallback-locale=` keep working through 1.x with a
  [deprecation warning](./versioning#deprecations).
- **Errors** carry a `code` (`NOT_FOUND`, `VALIDATION_ERROR`…): check it instead of the message.
- **Security defaults**, release by release from [0.62](#v0-62) to [0.66](#v0-66).
- **GraphQL** needs `graphql` installed in your project ([0.68](#v0-68)); without it the app
  doesn't start (`Cannot find module 'graphql'`). npm, pnpm and Bun add it for you, Yarn doesn't.

### Checklist: changes that don't stop the app {#checklist}

| If your app… | Then |
| --- | --- |
| Relies on webhooks for `users` | List `users` in the webhook's `collections` ([0.60](#v0-60)). |
| Uses `isAdmin` for admins of one tenant | Use your own rule; `isAdmin` now means the whole site ([0.60](#v0-60)). |
| Expects `403` when changing a document the user can't read | It answers `404` now ([0.63](#v0-63)). |
| Runs behind its own reverse proxy that changes `Host` | Turn on trust-proxy, or admin writes fail with `403` ([0.64](#v0-64)). |
| Calls `GET <api>/jobs/run` with an admin's cookie | Use `POST`, or the cron secret ([0.62](#v0-62)). |
| Has a `cronSecret` shorter than 32 characters | Make it longer: the app won't start ([0.62](#v0-62)). |
| Sends mail through an SMTP server on port 587 without TLS | Set `requireTLS: false` ([0.65](#v0-65)). |
| Reads the GraphQL schema from production | Set `introspection: true` ([0.65](#v0-65)). |
| Takes cash on delivery with `manualAdapter` | Set `expiresIn: false`, or unpaid orders are cancelled after 3 days ([0.65](#v0-65)). |
| Calls `POST <api>/shop/confirm` without the shop client | Send the guest's cart too ([0.63](#v0-63)). |
| Has `unique` on a field inside a group, array or block | It was never enforced there; move it up or check it in a hook ([0.66](#v0-66)). |
| Takes payments with Stripe | Give the webhook endpoint API version `2026-09-30.endive`, or set `apiVersion` ([0.67](#v0-67)). |

### Steps

1. **Back up** the database and uploads ([Backups](./backups)). Migrations only go forward.
2. **Upgrade every package together**, in development first:
   ```sh [pm]
   npm install @easy-cms/core@latest easy-cms@latest
   ```
   with the other `@easy-cms/*` packages your app uses on the same line.
3. **Start the app** and fix what the config check names, until it starts without errors. Its
   warnings (unknown options, `unique` in a group…) are worth reading too.
4. **Find old names in your code**: run your tests with
   `NODE_OPTIONS=--throw-deprecation`, so anything still using a deprecated route or command
   fails loudly. Then go through [the checklist](#checklist).
5. **Create the migration**: `npx easy-cms migrate:create upgrade`. A release may add internal
   tables or columns; review the SQL and commit it.
6. **Try it on a copy of production data**: restore a backup into a scratch database, run
   `npx easy-cms migrate`, start the app and open the admin.
7. **Deploy**: back up, run `easy-cms migrate`, start the new version. Watch the server log for
   warnings on the first requests.

Upgrades from databases made by 0.10 and later releases are tested for every change, on SQLite
and Postgres.

**Rolling back:** restore the backup taken before the deploy, and deploy the previous version
of your app.

The sections below list each release's changes.

## 0.68 {#v0-68}

- **GraphQL needs `graphql` in your project:** `@easy-cms/plugin-graphql` no longer brings its
  own copy, so the plugin and your `extend` code use the same one. npm, pnpm and Bun install it
  for you; with Yarn, run `yarn add graphql`. Versions 16 and 17 both work.

## 0.67 {#v0-67}

- **Shop with Stripe:** the adapter asks for Stripe API version `2026-09-30.endive`, whichever
  `stripe` package (17 to 23) is installed. Create the webhook endpoint with that version, or
  set `stripeAdapter({ apiVersion })`.

## 0.66 {#v0-66}

- **`unique` inside a group, array or block logs a warning:** it was never enforced there. Move
  the field to the top level, or check it in a hook.
- **Multi-tenant:** staff of a tenant see others' memberships in that tenant only; checking the
  audit log (`POST <api>/admin/audit/verify`) is for admins of all tenants; a plugin's
  collections listed only partly per tenant log a warning.

## 0.65 {#v0-65}

- **Shop: unpaid bank-transfer orders are cancelled after 3 days** and their stock put back
  (`manualAdapter({ expiresIn })`, in seconds; `false` to keep them, e.g. for cash on delivery).
  Orders made before the upgrade aren't affected. Orders get an `expiresAt` field: in production
  run `easy-cms migrate:create` and deploy the migration.
- **GraphQL introspection is off in production by default.** Set `introspection: true` on
  `graphqlPlugin()` to keep it; `easy-cms generate:graphql` still writes the schema for codegen.
  See [GraphQL](./graphql#options).
- **SMTP on port 587 requires STARTTLS.** For a server without TLS, e.g. a local mail catcher,
  set `smtp({ requireTLS: false })`. See [Email](./email).
- **Backups can be encrypted** with the optional `backups.encryptionKey`; decrypt a file by hand
  with `easy-cms backup:decrypt`. See [Backups](./backups#from-the-admin).
- **Without the audit log, failed sign-ins go to the server's log** as warnings.
- **Forms without a client IP share one larger rate limit** (ten times `max`) instead of none.

## 0.64 {#v0-64}

- **Cookies are named `__Host-ecms-session`, `__Host-ecms-csrf` and `__Host-ecms-sso` over
  HTTPS.** Cookies under the old names are still read, so nobody is signed out.
- **Behind your own reverse proxy that changes the `Host`, enable trust-proxy** (`trustProxy`,
  or `--trust-proxy` for standalone). The CSRF origin check uses `X-Forwarded-Host` only then, so
  without it admin writes may fail with `403`. Vercel and Netlify need nothing. See
  [Security](./security#checklist-before-going-live).
- **In production the API and the admin send `Strict-Transport-Security`** (HSTS, one year).
  Redirect HTTP to HTTPS at your proxy or host.
- **The SEO plugin's sitemap and `robots.txt` use `serverURL`** for their own addresses instead of
  the request's host. Set `serverURL` in production.

## 0.63 {#v0-63}

- **Updating or deleting a document the user may not read answers `404`** instead of `403`.
- **Preview links from the admin read with the access of whoever made them**; `createPreviewToken`
  takes `{ user }` for the same.
- **Shop: `POST <api>/shop/confirm` needs the payment's customer or the guest's cart** (`cart:
  { id, secret }`). The client sends it; custom pages that call the endpoint themselves must too.

## 0.62 {#v0-62}

- **Common passwords are refused:** setting a password that is one of the ~2,000 most common
  (`password1`, `iloveyou`…) fails with a validation error. Existing passwords keep working.
- **`cronSecret` must be at least 32 characters**, like `secret`: a shorter one in the config
  stops startup; a shorter `CRON_SECRET` is a warning. Generate one with `openssl rand -hex 32`.
- **`<api>/jobs/run` with the session cookie takes `POST` only** (`405` for `GET`). Crons with
  `Authorization: Bearer <cronSecret>` still use `GET`.

## 0.60 {#v0-60}

0.60 settles the names and shapes kept through 1.x. Most apps only need the config changes
(the error tells you each one) and, if they call them, the renamed Local API methods.

### Config

| Before | Now |
| --- | --- |
| `admin.siteUrl` | `admin.siteURL` |
| `admin.menu` | `admin.order` on each collection, global and page |
| `icon`, `editIn` of a collection or global | `admin.icon`, `admin.editIn` |
| `position` of a field | `admin.position` |
| `admin.defaultValue` of a field | `admin.initialValue` |
| `to` of an `admin.commands` entry | `href` |
| `commands` | `cliCommands` |
| `auth.allowSignUp` | `auth.providerSignUp` |
| `auth.members.signup` | `auth.members.signUp` |
| `audit.keep` | `audit.keepDays` |
| `audit.scope(context)` | `audit.scope({ context, user })` |
| `versions.max` | `versions.keep` |
| `backups.every: 'day' \| 'week'` | `backups.frequency: 'daily' \| 'weekly'` |
| `localization: null`, `admin.switcher: null` | `false` |
| `notEquals` in an `admin.condition` | `not_equals`, as in `where` (`not_in` too) |

Hooks get `previousDoc` (it was `originalDoc` in `beforeValidate` and `beforeChange`). Options Easy CMS doesn't know are
now logged as warnings, with the closest name it knows.

Other changes in behaviour:

- **Webhooks** no longer send `users` events unless the webhook lists `users` in its
  collections.
- **Field access** has `create`. Without it, `update` applies when creating, as before.
- Globals have a `beforeValidate` hook, and `admin.group: false` hides a collection or global
  from the menu.

### Local API and access

| Before | Now |
| --- | --- |
| `isLoggedIn` | `isStaff` (`isSignedIn` is unchanged) |
| `cms.scheduled()` | `cms.findSchedule()` |
| `cms.scheduledGlobal()` | `cms.findGlobalSchedule()` |
| `cms.upcomingJobs()` | `cms.upcomingSchedules()` |
| type `ScheduledJob` | `ScheduledPublish` |
| `localStorage()` storage | `diskStorage()` (`DiskStorageOptions`) |

- `isAdmin` no longer allows admins of one part of the site (a user with `scoped: true`, e.g.
  the admin of one tenant). Use `isSystemAdmin` or your own rule if you relied on it.
- `cms.runJobs()` (and `GET <api>/jobs/run`) answers
  `{ scheduled: { ran, failed }, jobs, webhooks, emails }`.
- `cms.increment()` throws `NotFoundError` when the document doesn't exist. `null` now only
  means the value would go out of bounds.
- `cms.audit.list({ ...filter, page, limit })` answers like `find`: `{ docs, totalDocs, page, … }`.
- Helpers that only `@easy-cms/*` packages use moved to `@easy-cms/core/internal`, which is not
  covered by semver. Import from `@easy-cms/core` (or `@easy-cms/core/plugin`) only.

### Errors

Every `EasyCMSError` has a `code`, and REST errors carry it: `errors[].code`, e.g.
`VALIDATION_ERROR`, `UNAUTHORIZED`, `NOT_FOUND`, `TOO_MANY_REQUESTS`. GraphQL uses the same
codes. Check `code` rather than the message.

- `ConfigError` is an `EasyCMSError` (`CONFIG_ERROR`).
- `429` answers send `Retry-After`.
- Requests that start a session (login, first register, reset password) answer `200`.
- Cancelling a scheduled publish answers `{ deleted: 1 }`.

### REST routes

Only apps that call the REST API themselves need these.

- Signing in moved to `<api>/auth/<action>`: `login`, `logout`, `me`, `init`, `signup`,
  `verify-email`, `forgot-password`, `reset-password`, `first-register`. The old
  `<api>/users/<action>` paths keep working through 1.x.
- Globals are updated with `PATCH` (`POST` still works).
- `?fallbackLocale=` (`?fallback-locale=` still works).
- What only the admin uses moved to `<api>/admin/ui/*`, outside semver. Don't call it from
  your app.
- A plugin endpoint on a path the API also has, but with another method, now leaves that
  method to the API.

### CLI

| Before | Now |
| --- | --- |
| `easy-cms create-admin` | `easy-cms admin:create` |
| `easy-cms run-scheduled` | `easy-cms jobs:run` |

The old names still work. Built-in commands reject options they don't know, so a typo like
`--outt` is an error instead of being ignored.

### Plugins

Options whose names changed; the old names stop startup with the new name.

| Plugin | Before | Now |
| --- | --- | --- |
| SEO | `siteUrl` | `siteURL` |
| Forms | `fields` | `fieldKinds` |
| Forms | `minSubmitTime` (ms, 2000) | `minSubmitSeconds` (2) |
| Forms | `fetch` | removed |
| Multi-tenant | `tenantsSlug` | `slugs: { tenants }` |
| Multi-tenant | `findTenantFor(cms, by, slug)`, `tenantContext(cms, by, slug)` | `{ …by, collection: slug }` |
| Redirects | `slug` | `slugs: { redirects }` |
| Redirects | `cacheTTL` (ms, 60000) | `cacheMaxAge` (seconds, 60) |
| Nested docs | `fields` | `fieldNames` |
| GraphQL | `exclude` | `collections` and `globals`: the slugs in the schema (default all) |
| Shop | `customers.signup`, `signup()` | `customers.signUp`, `signUp()` |

Types: `FormBuilderOptions`, `MultiTenantOptions` and `EcommerceOptions` are now
`FormBuilderPluginOptions`, `MultiTenantPluginOptions` and `EcommercePluginOptions`.

Adapters:

| Adapter | Before | Now |
| --- | --- | --- |
| `sqlite()` | `busyTimeout` | `busyTimeoutMs` |
| `s3Storage()` | `publicUrl` | `publicURL` |

### Writing plugins

- Import `definePlugin`, `defineFieldType`, the plugin types and the error classes from
  `@easy-cms/core/plugin`. It has no server code, so admin elements can import it too.
- Declare the plugin API you wrote for: `definePlugin(fn, { name, version, apiVersion: 1 })`.
  A plugin for another version stops startup and says what to upgrade. Adapters can set
  `apiVersion: 1` too.
- `onRequest` may be a list. Plugins that add one should add to the list instead of calling the
  previous function: each sees the user the one before returned, and contexts are merged.
- `cliCommands` get `flags`: `--dry-run` is `flags.dryRun`, `--limit=5` is `flags.limit`.
- Admin element tags can be any custom element name with a hyphen, not only `ecms-…`.
- Dashboard widgets take an `id`, which roles use instead of the tag.
- `warnDeprecated(code, message)` logs a deprecation warning once per process, for options of
  your own that you rename. See [Versions and deprecations](./versioning#deprecations).

Next.js: `createRouteHandlers(config, options)` takes `basePath` and `getClientIp` as well as
`trustProxy`.
