Skip to content

3.6.3

Choose a tag to compare

@joeworkman joeworkman released this 23 Oct 22:26
· 140 commits to master since this release
3.6.3
6450ab3

Security

  • Data views over MCP follow the Data Views edition and the dataviews permission, as on REST. The view tools and tcms://view/{id} resources checked only the view's own MCP access, so every signed-in OAuth member could read a view opened to signed-in callers, whatever their groups, and views stayed reachable on an edition without Data Views. A member now needs a group with the dataviews permission (public views stay open to everyone), and without the edition the view tools and resources are not offered, public views included.
  • An API key limited to some collections is limited over MCP too. A key that listed /mcp reached every collection through MCP whatever else it listed. A key's paths now decide what it reaches there as they do on REST: /collections/{id}, /schemas and /templates grant those (schemas, collection settings and templates read-only, as REST's structure routes take no API key), /mcp only opens the endpoint, and only a key with All endpoints (*) and every method is an admin over MCP. A key's methods decide its MCP tools as they decide its REST access: a GET-only key connects read-only, and a key without PUT or PATCH gets no update tools. A limited key reaches a collection or data view only when that one's MCP access is API keys, authenticated or public, never admin only. Check keys that list /mcp: one with nothing else now reaches no content. See MCP Server
  • Bot protection for public forms. A botProtection form option, and a Require Bot Protection setting on the collection that turns it on for every public register or create form, add a honeypot field and a signed time-trap token. The register endpoint, anonymous public creates and public clones screen them: a filled honeypot, a submission under three seconds, or a missing or forged token gets a success-shaped reply with nothing saved, no email and no event. An expired but genuine token says "This form has expired. Reload the page and try again." instead, so a cached page or an old tab never loses a submission silently. Signed-in callers (session, API key, OAuth) skip the check. The token is an HMAC over time, collection and form type, keyed from the site key, so it cannot be forged or reused on another form. Forms without the option are unchanged. See Form Options
  • Bot protection covers form mail. A form's mailer action is a second request to the public /api/action/mailer endpoint, so the save's bot check never covered it. The endpoint now screens the form's bot fields, and a Mailer template with Require Bot Protection refuses anonymous sends without them. Create and register forms whose mailer action uses such a template render the fields without needing the botProtection option. See Mailer
  • Twig no longer hands templates stored password hashes. Object reads, collection-URL object pages, page hydration, the admin page preview and cms.auth.userData() return records without password properties. Login, password reset and passkeys still read the full record internally
  • Redirects after login, verification and password reset stay on the site. One same-origin check now guards all of them and rejects backslashes and control characters before anything else, which closes open redirects such as /\evil.com and https://evil.com\@example.com that the old login rule let through. The password-reset redirect previously had no check at all
  • Sync import needs an admin. POST /api/sync/import now requires an admin session, as its export sibling does (API keys are unaffected); any signed-in user could previously push collections, schemas and templates
  • MCP create_schema seeds only the collection it creates. If a collection with that id already exists the schema is saved and seeding is refused, so seedObjects can no longer write around create_object's checks
  • Downloading log files needs the Log Analyzer permission. The log download route checked only that a user could open some utility, so an access group granted, say, the Cache Manager could download every log file. It now checks the Log Analyzer grant, as the page does; admins are unaffected
  • Extension manifests are escaped in the admin. An extension's name, description, version and author are escaped on the list, review, settings and health pages, and the version is limited to a safe character set

Added

  • Activity Log: who changed, downloaded or called what, and when. Utilities → Activity Log is a filterable table of site activity, newest first: object, collection, schema and template changes (updates list the changed field names, never values), logins, file downloads by signed-in users, refused requests, imports, bulk deletes, and MCP tool calls with the tool, its target and its duration. Each row names the user, API key or OAuth client behind it, so everything an AI client a member connected did is one filter away. Filter by event, actor, user/key/client, collection, level and date. Export CSV downloads every row the filters match. Rows live in SQLite at tcms-data/.system/activity and are kept 90 days by default; Settings → Activity has the switch, the retention and whether to record anonymous MCP calls (off, since a public MCP server listed in an AI directory takes thousands). Every edition. See Activity Log
  • A file.downloaded event. Fired when a file is served from a /download/... link after its protection passed, with the collection, object, property, file name and whether the file is protected; actor says who downloaded it. Extensions and event-triggered automations can react to it, for example to email someone when a member downloads a contract. Inline /stream/... playback does not fire it. See Extension Events
  • tcms collection:update changes an existing collection's settings. --set ownerProperty=author sets one setting, a dot reaches into a map (--set mcp.access=authenticated, --set properties.price.label=Cost), and JSON from a file or stdin handles bigger changes. Maps merge key by key as the MCP update_collection tool does, through the same service, and the save is validated as the collection form validates it. Before, changing ownerProperty, a URL or the sort after collection:create meant the admin, the API, or editing .meta.json by hand.
  • tcms access-group:list, access-group:get, access-group:set and access-group:patch. Access groups had no CLI at all. Definitions are checked against the stored shape, so an unknown section, key or operation is refused instead of saving a group that quietly grants nothing. Writes need the Access Groups edition, as the admin page does.
  • update_collection and delete_collection over MCP. An agent can change a collection's settings after creating it, field overrides included: settings maps and overrides merge, so an agent sends only what changes, and an override's field type is filled in from the schema. Deleting a collection removes its settings only, as in the admin: objects stay, and the result carries the settings to recreate it. Login and system collections are refused, and a storage format change is refused with a pointer to tcms collection:convert. Both are for admins and for OAuth members whose access groups grant collection settings; a limited API key never gets them. create_collection now says it takes field overrides. See MCP Server
  • delete_object over MCP. An agent can delete one object, gated like the other write tools: an admin, an OAuth member whose groups grant delete on the collection (their own objects on an owned collection), or an API key whose endpoints name the collection and whose methods include DELETE. It is marked destructive, so clients ask first. The record stays in the backup history; uploaded files are deleted, and the result says which fields held them. The OAuth Grants page and the access group visualizer now count delete as MCP reach. See MCP Server
  • An "API keys" MCP access level for collections and data views. Between Admin only and Authenticated: admins, plus API keys whose endpoints name the collection or view. Give an integration a key for one collection without opening that collection to signed-in members over OAuth or the site's own pages. /dataviews and /dataviews/{id}/data in a key's endpoints now grant views over MCP too. See MCP Server
  • $ctx->publicHttp for automations. A handler that fetches a URL from content — a member's website, a feed — can GET it without the URL reaching this server's own network: the host must resolve only to public addresses, the connection is pinned to the checked address, and every redirect is checked again. A 10 second timeout and a 2 MB read cap by default. See Automation Handlers
  • cms.badge: a "Built with Total CMS" badge in one line of Twig. The badges at totalcms.co/badges were paste-only HTML, frozen the day it was pasted: a new theme or a changed link never reached the sites already showing one. cms.badge.pill(), .stacked() and .text() write the generic badges, and .profile(handle) and .earned(handle) a designer's, linked to their profile on totalcms.co. The images are served by totalcms.co, so a designer's photo and approved count stay current. Themes are light, dark and auto, which follows the visitor's color scheme through a <picture>; the text badge takes color or mono. An invalid handle gives the pill, never a broken image. On a designer's own site, me: true adds rel="me" to a profile badge's link, so the site and the profile verify each other. See Badges
  • cms.render.picture() can show a different image in dark mode. Pass dark: 'logo-dark' (another object or ID, resolved with the same collection and property) or darkProperty: 'imageDark' (a second image property on the same object) and the <picture> leads with one <source media="(prefers-color-scheme: dark)"> per format for that image, before the light sources and <img>. The browser picks the first source whose media query and type match and re-evaluates it live, so the swap follows the visitor's theme with no CSS or JavaScript. The dark image gets its own candidates capped at its own width, with its stored format appended as a last resort since it has no <img> to fall back to; alt text, dimensions and the link stay the light image's. A missing, empty or GIF dark image is ignored. See Render → picture()
  • cms.media.imageDataUri(): an image embedded instead of linked. An SVG served as an <img> — a badge other sites paste in — cannot load anything from outside itself, so it could not show an uploaded photo at all: imagePath() gives a URL, and there was no way to get the image's bytes into a template. imageDataUri() takes the same arguments, renders through ImageWorks and returns a data: URI, or an empty string when there is no image, the render fails or the result is over 64 KB, so a template can fall back. See Media
  • cms.redirect(): a template answers its URL with a redirect. A page record's Redirect To covers a fixed move, but a forwarding URL whose target depends on data — a badge link pasted on another site that should reach a member's profile while it is public and the home page once it is not — had nowhere to go: the template could only render, or call cms.collection.redirectIfNotFound(), which is tied to a missing object. cms.redirect(url, status) stops rendering and the page router answers with the status (302 by default; 301, 303, 307 and 308 also accepted) and a Location header. A URL holding a line break is refused, so a target built from the request cannot inject a header. See Total CMS helpers
  • cms.notFound(): a template answers its URL with the 404 page, in place. A collection URL matches every object, drafts included, so a detail template that should not serve a record had only cms.collection.redirectIfNotFound(), which sends the visitor to the 404 page's own address — and answers 302, since PHP drops the 404 status it set once a Location header is sent. Crawlers and link checkers recorded a redirect rather than a missing page, and a guessed draft slug answered differently from a slug that never existed. cms.notFound() stops rendering and the page with status 404 renders at the visitor's URL with a real 404, exactly as an unmatched URL is answered. redirectIfNotFound() is unchanged for sites that want the redirect. See Total CMS helpers
  • The Article node's author can be a node, not just a name. author on the record was read as a string and became {"@type": "Person", "name": …} — so a site that declared a full Person (with url and sameAs) as an extra JSON-LD node still handed search engines an anonymous author, since the two nodes were never linked and the author is the one they read. An array author is now emitted as the author node itself, @type: Person added when it has none, so a template can pass {'@id': base ~ '/about#founder'} and the article points at the Person it already declares. A string still works as before. See SEO
  • A template's JSON-LD node can extend a core node. An extra node whose @id matched one core emitted was dropped, so there was no way to say anything about the Organization beyond what the Site SEO record holds. It now adds its properties to core's node — core's own values stand — so a page can pass {'@id': base ~ '/#organization', 'founder': {'@id': base ~ '/about#founder'}} and the Organization names its founder, matching the Person's worksFor in the other direction. See SEO
  • Owned collections: members see and edit only their own objects. A collection may name an ownerProperty, picked in the collection form from the schema's properties that can hold a user id. Anyone but a super admin is then scoped to the objects they own: another member's object is denied, list and query routes return only theirs, and routes that cannot be scoped (bulk delete, reindex, clone, import, export, duplicate) are refused. A list-typed owner property holds several owners; a new object is stamped with its creator. ownerCollection names the auth collection whose members may own objects, so a user from another auth collection is treated as anonymous. Over MCP, the same rules hold: a member granted the collection reads their own objects, drafts included, plus everyone's published ones on a public collection, creates objects stamped as theirs and edits only their own, and describe_collection says so up front. One access guard now serves REST, MCP, forms and Twig, and every refusal fires an access.denied event. See Collection Settings
  • Protected fields. A schema property may be protected: true (a non-admin's change is reverted and logged) or protected: {"value": X} (forced to X on every non-admin save), across every write path, uploads and deck items included, so a moderated object cannot be left approved. The approved toggle of the reserved totalcms schema is protected, and schema:lint rejects an invalid value
  • Public registration from the collection form. A collection built on the auth schema (or one inheriting it) now has a Public Registration section in its settings: Allow Public Registration, Require Email Verification and Registration Groups. Registrants receive the global publicRegistrationGroup plus the collection's groups, never admin. Only super admins and API keys may change these settings. Collections listed in auth.publicRegistration keep working. See Collection Settings
  • verifyRedirect on register forms. A register form can pass verifyRedirect (e.g. /login) so the email verification link lands there instead of the admin login; an expired link carries it through the resend form, which is now always the collection's own
  • Install an extension from a zip. The Extensions page has an Install Extension drop zone, and the CLI gains extension:install <zip> [--force] and extension:package [dir]. A fresh install opens the review page only when there is something to review; an update keeps state, permissions and settings. User-installed extensions get a Remove button, and extension:remove names the owner when it refuses. Install and remove need an admin browser session. See Extensions
  • extendFieldType(): an extension field type built on a core one. An extension field that adds to a core field (an image field with a capture button, say) can subclass the core class instead of starting from TotalField. Exposed on window.TotalCMS in admin.js and forms.js. See Extension Points
  • A countries option source and a country_name filter. "propertyOptions": "countries" fills a select with ISO 3166-1 alpha-2 codes labeled in the site's locale, and country_name shows a stored code as its name. No PHP intl extension needed. See Property Options
  • cms.render: alt and protect options. alt sets an image's alt text, and an empty string marks a decorative image, instead of falling back to the image's alt field, EXIF or the filename. protect: false drops the draggable="false" and oncontextmenu attributes every rendered <img> carried, with imageworks.protect as the site-wide default. Both work on image() and picture(); protect covers galleries too
  • fieldIcons on the login form. cms.form.loginForm({fieldIcons: false}) drops the icon slots beside email and password, as the form builder already could
  • The form builder's formgrid option lays out schema forms. cms.form.builder('users', {formgrid: ...}) was ignored for schema-driven forms; it now wins when given, so a front-end form can arrange a subset of a schema's fields without changing the admin layout
  • Every event payload carries actor. Listeners and automations see who caused an event (kind is system, apikey, admin, member or anonymous, with the user, OAuth client or API key behind it), captured when it fired, so a queued listener still records the person. Work the system does on someone's behalf (an automation an admin ran, a registration, a login stamp) names that person in the actor's initiator. The CLI, automation runs and queued jobs act as the system. See Events
  • Agent skill: troubleshooting steps and a support report template for a suspected Total CMS bug; a recipe for content members submit and edit (ownership, protected review flags, account pages, member AI clients); ordering a list without a sort field; the new CLI commands; and a review pass against the current code
  • OAuth login collection. Settings → OAuth Server → Login Collection names the auth collection whose members connect an AI client or authorize an integration, for a site whose members live in their own collection (such as users). A signed-out caller is sent to that collection's login page, where staff from the default user collection can also sign in during an OAuth flow. A session from any other auth collection cannot authorize and is logged as login_collection.rejected. Unset, OAuth behaves as before. See OAuth Server

Changed

  • The MCP tool list follows a connection's OAuth scopes. A member's token approved for cms:read only no longer lists create_object, patch_object, delete_object or the other write and structure tools; before, they were listed and refused when called. Content read tools stay listed without cms:read, since public collections need no scope, and admins still see every tool.
  • list_schemas and get_schema follow the schemas read grant over MCP. They were admin only. Now, as with the REST schema reads, an OAuth member whose groups grant schema read, or an API key whose endpoints name /schemas or /schemas/{id}, reads the schemas it may, and list_schemas lists only those. Changing schemas stays admin only for keys.
  • The Article headline and breadcrumb leaf are the record's title, not the browser title. Both used the resolved page title, so a record with an authored SEO title (Making a PHP app legible to AI agents — Total CMS) declared that whole string as its headline — the site suffix included — while its H1 said otherwise. They now use the record's own title, falling back to the page title only when there is none; <title> and og:title are unchanged
  • The WebPage node's datePublished is the record's date, else created — the same choice the Article node has always made, instead of created alone. A post dated 1 October and created on the 5th reported both dates on one page, one per node
  • A card in the collection table shows its first text field. An indexed card's cell joined every scalar sub-field, so an SEO card read as "About Us, Who we are, 1, 1". It now leads with the first text field in the card schema's order (skipping id, hidden fields, styled text, code, SVG, JSON and passwords); a card with none, or with it empty, shows its first two values. Decks still show their item count
  • ImageMagick's memory is capped per render. ImageMagick allocates outside PHP's memory manager, so memory_limit never bounded it, and its own default on a 4 GB host is 1 GB per PHP worker. Resizing a 24 MP photo peaked at 268 MB per worker; a few at once can push a small host into swap. Total CMS now caps ImageMagick's in-memory pixel cache at 64 MB and its memory-mapped cache at 128 MB before any render, HEIC conversion or palette extraction, and ImageMagick stages anything larger on disk instead. The same resize now peaks at 86 MB. Output is byte-identical; a first (uncached) render of a large source takes roughly 10–20% longer, and typical 12 MP photos are unaffected. A host whose ImageMagick policy is already stricter keeps its own limits
  • pdo_sqlite is required software. The job queue (imports, automations, bulk mail) and the bulk mailer's send log are SQLite files, but the Server Checker, tcms check and the setup wizard did not list the extension, so a host whose cron PHP lacks it only showed up as jobs:process failing every minute with "could not find driver". All three now check for it, and the requirements page says to check the cron PHP as well as the web server's
  • Stacks 6 admin uses the "Stacks" session name. Upgrade note. Stacks 6 opens every page's session as Stacks, but admin requests fell through to PHPSESSID, so a login was stored in one session and Page Lock checked the other. Stacks 6 admins are logged out once on upgrade as the login moves cookie; Stacks 5 is unchanged
  • The update check gives up faster. An unreachable license server cost every dashboard load the full timeout, twice. The timeout is now 5 seconds and a failed check is cached for an hour
  • Faster requests. One shared Slugify and one shared Serializer instead of a new one per record (a listing page spent about 18% of its time building Slugify rules), and extension entry files are tokenized only up to their class line. From a customer's profile of 3.6.2
  • A new capability on an enabled extension logs a warning. A capability first detected at boot is stored off by design; the log now names the extension, the capabilities and how to grant them
  • CLI commands that confirm before acting fail when nobody can answer. object:delete, backup:restore, update:apply, update:rollback and extension:remove run from a script without --force now exit 1 and name --force, instead of exiting 0 having done nothing or waiting on an open pipe
  • automations:process reports the queued jobs it ran, not only the scheduled automations it fired, so a run that drained the queue no longer says "Fired 0"
  • Verification and reset emails state their expiry in hours when the lifetime is whole hours
  • The JumpStart utility sorts collections by name, not id
  • Front-end libraries updated, among them Tiptap 3.32, CodeMirror, marked 18, Mermaid 12 and Choices 11.2.4. The list field's drag-to-reorder now works on the official Choices release, using Sortable's pointer-based dragging in every browser. The data visualizer keeps its dagre layout and classic look under Mermaid 12
  • MCP server on mcp/sdk 0.9. Parallel calls in one session keep their own answers and progress, a server-sent notification no longer rides inside a response as a JSON array (which current clients reject), and an unreachable session store answers 503 instead of "session not found". /.well-known/mcp.json now advertises 2026-07-28. A tool's structuredContent is now checked against its outputSchema, and a mismatch reaches the client as a tool error instead of the bad value — an extension tool whose output drifted from its schema now fails visibly. Return new \stdClass(), not [], for an empty object

Removed

  • A page URL no longer answers with JSON for ?_format=json or Accept: application/json. The page router returned the matched builder page or collection record as JSON before the template ran: every field of the record, whether or not the template showed it, and past any cms.notFound() guard, on an Accept header some fetch clients send by default. Nobody asked for it and the REST API is the headless surface, with API keys and access groups in front of it. Those requests now render the page like any other, and the auth page middleware answers them with the login redirect rather than a JSON 401

Fixed

  • An extension the system turns off is no longer turned off silently. Disabling after an update added risky code, or quarantining after repeated crashes, happens on a request nobody is watching, and the only sign was a banner on the Extensions page. Every admin page now shows admins a warning naming the extension and why, linking to its settings, until someone re-enables it or disables it themselves
  • Updating an extension no longer disables it for code you already approved. The update check disabled any enabled extension whose new version had a risky pattern at all, so one that makes HTTP calls or writes files was turned off on every version bump, and again when the update reached production. Enabling now records the findings you reviewed, and an update is disabled only for findings beyond them; the banner lists just those. Extensions enabled before this release take their current findings as approved on their next update
  • Saving a schema no longer replaces a field type the editor does not know. A property using a disabled extension's field type had no option in the editor's field-type list, so the list fell back to code and the next schema save wrote code over it. The type now keeps its own option under "Unavailable", and schema:lint warns about a field type that neither core nor an enabled extension provides, and about a field that stores a different shape than its $ref
  • The OAuth Grants page shows what a member really reaches over MCP. It listed a collection left at Admin only as readable whenever the member's groups granted read, though MCP refuses it, and it showed owned collections as closed to the member's group grant, though members read and write their own objects there. Reach now comes from the same exposure check and AccessGuard the MCP tools use, and owned collections are marked "own". The access group visualizer shows group grants on owned collections too.
  • MCP can edit image, gallery, file and depot metadata. An agent could not fix alt text, tags, a focal point or a download label: any value in a media field was refused. update_object and patch_object now merge a metadata edit over the uploaded file — an object for an image or file, a list matched by name for a gallery and by path for a depot — while uploads, renames and file protection stay in the admin. describe_collection lists the editable keys. The REST meta route the admin's gallery and depot dialogs use now keeps a file's name, size, hash and other identifying keys whatever a request sends. See MCP Server
  • An MCP call that changes the tool surface keeps its own session. Creating a schema or collection over MCP drops every connected client's session so they reload the tool list; the calling client's session went too, so a create_schema reporting progress failed with an internal error
  • A content save no longer drops MCP sessions on a collection saved before a setting existed. A record missing a later-added default (an mcp block without tools, say) read as a settings change the first time an object save rewrote it
  • Restricted MCP origins accept the site's own page over https behind a proxy. With mcp.allowedOrigins set, a configured origin is matched in full (scheme, host, port), and the site's own origin is accepted whether the proxy hands T3 the request as http or https
  • The automation form's code editor stops growing at 600px. A long handler made the PHP editor as tall as its code; it now scrolls inside a 600px box, as the template, data view and mailer editors do
  • A Site Builder page that serves an image no longer opens a session. A page whose URL ends in an image extension (a badge at /badges/pill.svg, an OG card) started a PHP session on every request, so an image embedded on other sites opened a session file, held its lock and sent a PHPSESSID cookie on every view of every page it sat on — and the Set-Cookie kept any CDN from caching it. Front-end URLs ending in .svg, .png, .jpg, .gif, .webp, .avif or .ico now run without a session, like ImageWorks and asset URLs; admin and API URLs keep theirs
  • A 404 no longer resets OPcache. The default error handler reset the pool-wide bytecode cache on every exception it answered, so each bot probing /wp-login.php and the like recompiled the whole site on the next request — and on a shared pool, every co-located site with it. The reset exists so a fixed file takes effect right after a fault; it now runs only for a real fault, never for an HTTP exception, and the X-OPcache-Cleared header is sent only when a reset actually happened
  • The table format works for API-key callers. GET /api/collections/{collection}/query?format=table answered 500 "No User ID provided" when authenticated with an API key: the renderer asked the access control whether the session user may inline-edit, and an API-key request has no session user. An empty user is now a plain "no" — no pencil, same table
  • A property with "type": "integer" saves. The docs and the agent skill recommend it for whole numbers, schema:lint accepts it and the reserved schemas use it, but every save of an object with one failed with "Unknown property type for object": the property factory builds its class from the type and there was no IntegerData. There is now; a whole number is stored as 3, not 3.0, a default applies, and 2.5 is still refused by schema validation. The error for a type that really is unknown now names the type and the property (Unknown property type "foo" for property "position"). Reported by Marcelo
  • An autogen id and a calc follow the field they name, whatever its type. "autogen": "${name}" listened for a native change on the first input in the form called name. Two things went wrong with that. An image or file field renders a readonly Filename sub-input with that same name, so when the schema's form order put the image before the Name field, as the auth schema does, typing a name did nothing and only editing the Filename regenerated the id. And most field types (Styled Text, date, color, list, card, …) set their value in code and never fire a native event, so ${title} from a Styled Text title never ran at all. Both now listen for the field's own change event, scoped to the form's or deck item's top-level fields, so any field type works as a reference and a composite's sub-inputs are never matched. The id now regenerates as you type, a moment after you pause, instead of when you leave the field
  • A field visibility rule follows a Styled Text, date, color, list or card field. A watch rule re-evaluated only on a native change from the watched field, and those field types set their value in code without firing one, so a field shown or hidden by one of them did not react until something else on the form changed. The rule now also listens for the field's own change event; a plain text or select field still answers at once
  • empty and not_empty visibility operators work on any field. They only ever worked when the watched field held an array (checkbox, multiselect, list); on a text, image or card field they fell through to a plain comparison, and the form's server-side render hid the field whatever the value because value is null for these operators. A field is now empty when it is blank text, an array with no items, or an image, file or card with nothing in it, in the browser and on first render alike. 0 and an unticked toggle are values, not empty. See All Field Settings
  • Total CMS no longer logs visitors out of other PHP apps on the same site. When a Stacks page or another app had already opened the session, Total CMS destroyed that session file before reopening its own. An app with session.use_strict_mode on (VibraCart Pro, for one) then rejected the session, so visitors were logged out and carts emptied themselves. The host's session is now closed, not destroyed, and a host session in a different folder is logged at info. See Sharing Sessions. Reported by StacksWeaver
  • A cropped image is sized from its crop. With a crop rect, image() and picture() used the whole upload's dimensions, so the <img> was sized wrong and srcset ran to descriptors far wider than the image delivered, giving large screens a small image. Reported by StacksWeaver
  • Clicking Save always sends, even after a failed save. A failed request left the form refusing every later attempt with "Error in form, cannot save" until a field changed; the Save button is now the retry
  • A save right after correcting a field no longer fails with "Error in form, cannot save". Validation now waits for the field's pending change before deciding
  • A form's "id taken?" check works on a root install. Register forms and admin-routed forms asked the wrong URL, a 404 every time, so every handle looked free; the mailer action had the same flaw
  • A new form no longer steals focus on a public page. It leaves focus alone when another field has it or carries autofocus, and never scrolls to a form below the first screen
  • Front-end forms with an image field no longer request an admin tool. The embed loader treated the upload field's link dialog as a cms.embed() iframe, a 403 for members
  • Fields rendered without an icon keep their full corners: the styled text, markdown, JSON, code, SVG and select fields squared their right corners anyway
  • The WordPress import no longer times out on images, and keeps categories. Featured images download inside the import job instead of the upload request (a 273-image export took close to a minute), and a failed download imports the post without its image. Categories and tags go to categories and tags, the author is the display name, images keep their filename, and block editor markers are stripped
  • The trim filter takes Twig's three arguments. Total CMS shadowed Twig's trim(characterMask, side) with PHP's two-argument function, so trim('x', 'left') threw
  • A JSON Schema list type such as ["string", "null"] no longer crashes jumpstart:import and schema:lint
  • Extension Twig globals reach templates. An extension that registered only globals got none, while the admin showed Twig Globals granted
  • An air-gapped site with a rejected offline license file says so, with the reason and the domain fix, instead of a misleading "DNS verification required"
  • Gallery reordering no longer throws when part of the field failed to build, and admin API errors show the server's message instead of "Unknown error"
  • No more "mkdir(): File exists" when a web request and jobs:process create the same directory at once
  • A form with a field named style loads. Conditional visibility wrote to the form's inline style, and a browser resolves form.style to a field of that name, so the form threw "removeProperty is not a function" on load
  • A styled markdown field's toolbar leaves out buttons it cannot run. A toolbarConfig naming a styled text button such as Indent, Underline or an alignment showed it, and clicking it threw, since the markdown editor has no such command

Documentation

  • Sharing Sessions. A new Operations page explains how to share $_SESSION with other PHP apps on the site since 3.6 moved sessions to <datadir>/.system/sessions, the split-session symptoms, and how to confirm them. See Sharing Sessions
  • Reference groups sort themselves. Operations, Field Types and Bundled Extensions list their pages alphabetically
  • The licensing page explains the domain-match requirement for offline license files, and points a client handoff at the freelancer kit
  • Total CMS in the ChatGPT and Claude directories. The AI integration page and the welcome page link the ChatGPT listing and say to search Claude's directory for Total CMS. See Using Total CMS with AI
  • The MCP CORS docs match the code. An empty mcp.allowedOrigins allows any origin, as the settings help already said; the MCP page claimed it blocked browsers. A listed origin is matched in full, and the extension docs say outputSchema is now enforced. See MCP Server