Skip to content

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.

sh
npm install @easy-cms/core@latest easy-cms@latest
sh
pnpm add @easy-cms/core@latest easy-cms@latest
sh
yarn add @easy-cms/core@latest easy-cms@latest
sh
bun add @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 ​

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, POST to 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 usersList users in the webhook's collections (0.60).
Uses isAdmin for admins of one tenantUse your own rule; isAdmin now means the whole site (0.60).
Expects 403 when changing a document the user can't readIt answers 404 now (0.63).
Runs behind its own reverse proxy that changes HostTurn on trust-proxy, or admin writes fail with 403 (0.64).
Calls GET <api>/jobs/run with an admin's cookieUse POST, or the cron secret (0.62).
Has a cronSecret shorter than 32 charactersMake it longer: the app won't start (0.62).
Sends mail through an SMTP server on port 587 without TLSSet requireTLS: false (0.65).
Reads the GraphQL schema from productionSet introspection: true (0.65).
Takes cash on delivery with manualAdapterSet expiresIn: false, or unpaid orders are cancelled after 3 days (0.65).
Calls POST <api>/shop/confirm without the shop clientSend the guest's cart too (0.63).
Has unique on a field inside a group, array or blockIt was never enforced there; move it up or check it in a hook (0.66).

Steps ​

  1. Back up the database and uploads (Backups). Migrations only go forward.
  2. Upgrade every package together, in development first:
    sh
    npm install @easy-cms/core@latest easy-cms@latest
    sh
    pnpm add @easy-cms/core@latest easy-cms@latest
    sh
    yarn add @easy-cms/core@latest easy-cms@latest
    sh
    bun add @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.
  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.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 ​

  • 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.
  • 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 with easy-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-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.
  • 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 ​

  • 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 ​

  • 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 ​

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 ​

BeforeNow
admin.siteUrladmin.siteURL
admin.menuadmin.order on each collection, global and page
icon, editIn of a collection or globaladmin.icon, admin.editIn
position of a fieldadmin.position
admin.defaultValue of a fieldadmin.initialValue
to of an admin.commands entryhref
commandscliCommands
auth.allowSignUpauth.providerSignUp
auth.members.signupauth.members.signUp
audit.keepaudit.keepDays
audit.scope(context)audit.scope({ context, user })
versions.maxversions.keep
backups.every: 'day' | 'week'backups.frequency: 'daily' | 'weekly'
localization: null, admin.switcher: nullfalse
notEquals in an admin.conditionnot_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 ​

BeforeNow
isLoggedInisStaff (isSignedIn is unchanged)
cms.scheduled()cms.findSchedule()
cms.scheduledGlobal()cms.findGlobalSchedule()
cms.upcomingJobs()cms.upcomingSchedules()
type ScheduledJobScheduledPublish
localStorage() storagediskStorage() (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 ​

BeforeNow
easy-cms create-admineasy-cms admin:create
easy-cms run-scheduledeasy-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.

PluginBeforeNow
SEOsiteUrlsiteURL
FormsfieldsfieldKinds
FormsminSubmitTime (ms, 2000)minSubmitSeconds (2)
Formsfetchremoved
Multi-tenanttenantsSlugslugs: { tenants }
Multi-tenantfindTenantFor(cms, by, slug), tenantContext(cms, by, slug){ …by, collection: slug }
Redirectsslugslugs: { redirects }
RedirectscacheTTL (ms, 60000)cacheMaxAge (seconds, 60)
Nested docsfieldsfieldNames
GraphQLexcludecollections and globals: the slugs in the schema (default all)
Shopcustomers.signup, signup()customers.signUp, signUp()

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

Adapters:

AdapterBeforeNow
sqlite()busyTimeoutbusyTimeoutMs
s3Storage()publicUrlpublicURL

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.

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

Released under the MIT License.