Upgrading
What you'll learn
What changed between versions, and what to change in your app when you upgrade.
Before this page: Configuration.
Upgrade every @easy-cms/* package and easy-cms together: they are released with the same version number.
npm install @easy-cms/core@latest easy-cms@latestpnpm add @easy-cms/core@latest easy-cms@latestyarn add @easy-cms/core@latest easy-cms@latestbun add @easy-cms/core@latest easy-cms@latestAdd 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
0.60 settled the names 1.0 keeps; the releases after it tightened security. 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 don't, so read it before you deploy.
What changed
- Names of config options, Local API methods, CLI commands and plugin options (0.60). A renamed config option stops startup and names the new one.
- REST routes: signing in moved to
<api>/auth/*; the old/users/*paths,POSTto a global and?fallback-locale=keep working through 1.x with a deprecation warning. - Errors carry a
code(NOT_FOUND,VALIDATION_ERROR…): check it instead of the message. - Security defaults, release by release from 0.62 to 0.66.
Checklist: changes that don't stop the app
| If your app… | Then |
|---|---|
Relies on webhooks for users | List users in the webhook's collections (0.60). |
Uses isAdmin for admins of one tenant | Use your own rule; isAdmin now means the whole site (0.60). |
Expects 403 when changing a document the user can't read | It answers 404 now (0.63). |
Runs behind its own reverse proxy that changes Host | Turn on trust-proxy, or admin writes fail with 403 (0.64). |
Calls GET <api>/jobs/run with an admin's cookie | Use POST, or the cron secret (0.62). |
Has a cronSecret shorter than 32 characters | Make it longer: the app won't start (0.62). |
| Sends mail through an SMTP server on port 587 without TLS | Set requireTLS: false (0.65). |
| Reads the GraphQL schema from production | Set introspection: true (0.65). |
Takes cash on delivery with manualAdapter | Set expiresIn: false, or unpaid orders are cancelled after 3 days (0.65). |
Calls POST <api>/shop/confirm without the shop client | Send the guest's cart too (0.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). |
Steps
- Back up the database and uploads (Backups). Migrations only go forward.
- Upgrade every package together, in development first:with the othersh
npm install @easy-cms/core@latest easy-cms@latestshpnpm add @easy-cms/core@latest easy-cms@latestshyarn add @easy-cms/core@latest easy-cms@latestshbun add @easy-cms/core@latest easy-cms@latest@easy-cms/*packages your app uses on the same line. - Start the app and fix what the config check names, until it starts without errors. Its warnings (unknown options,
uniquein a group…) are worth reading too. - 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. - Create the migration:
npx easy-cms migrate:create upgrade. A release may add internal tables or columns; review the SQL and commit it. - 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. - 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.66
uniqueinside 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
- Shop: unpaid bank-transfer orders are cancelled after 3 days and their stock put back (
manualAdapter({ expiresIn }), in seconds;falseto keep them, e.g. for cash on delivery). Orders made before the upgrade aren't affected. Orders get anexpiresAtfield: in production runeasy-cms migrate:createand deploy the migration. - GraphQL introspection is off in production by default. Set
introspection: trueongraphqlPlugin()to keep it;easy-cms generate:graphqlstill writes the schema for codegen. See GraphQL. - SMTP on port 587 requires STARTTLS. For a server without TLS, e.g. a local mail catcher, set
smtp({ requireTLS: false }). See Email. - Backups can be encrypted with the optional
backups.encryptionKey; decrypt a file by hand witheasy-cms backup:decrypt. See Backups. - 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
- Cookies are named
__Host-ecms-session,__Host-ecms-csrfand__Host-ecms-ssoover 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-proxyfor standalone). The CSRF origin check usesX-Forwarded-Hostonly then, so without it admin writes may fail with403. Vercel and Netlify need nothing. See Security. - 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.txtuseserverURLfor their own addresses instead of the request's host. SetserverURLin production.
0.63
- Updating or deleting a document the user may not read answers
404instead of403. - Preview links from the admin read with the access of whoever made them;
createPreviewTokentakes{ user }for the same. - Shop:
POST <api>/shop/confirmneeds 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
- 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. cronSecretmust be at least 32 characters, likesecret: a shorter one in the config stops startup; a shorterCRON_SECRETis a warning. Generate one withopenssl rand -hex 32.<api>/jobs/runwith the session cookie takesPOSTonly (405forGET). Crons withAuthorization: Bearer <cronSecret>still useGET.
0.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
usersevents unless the webhook listsusersin its collections. - Field access has
create. Without it,updateapplies when creating, as before. - Globals have a
beforeValidatehook, andadmin.group: falsehides 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) |
isAdminno longer allows admins of one part of the site (a user withscoped: true, e.g. the admin of one tenant). UseisSystemAdminor your own rule if you relied on it.cms.runJobs()(andGET <api>/jobs/run) answers{ scheduled: { ran, failed }, jobs, webhooks, emails }.cms.increment()throwsNotFoundErrorwhen the document doesn't exist.nullnow only means the value would go out of bounds.cms.audit.list({ ...filter, page, limit })answers likefind:{ 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.
ConfigErroris anEasyCMSError(CONFIG_ERROR).429answers sendRetry-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(POSTstill 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 setapiVersion: 1too. onRequestmay 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.cliCommandsgetflags:--dry-runisflags.dryRun,--limit=5isflags.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.
Next.js: createRouteHandlers(config, options) takes basePath and getClientIp as well as trustProxy.