Skip to content

v2.8.0

Choose a tag to compare

@github-actions github-actions released this 14 Aug 21:16
· 52 commits to main since this release
Immutable release. Only release title and notes can be modified.
e399727

Security

  • Plugin, theme, and AI mutation endpoints now enforce authorization, not just authentication — the plugin install / activate / deactivate / update / destroy routes and UploadThemeRequest / InstallPluginRequest gate deny-by-default on manage-plugins / manage-themes; the theme upload / activate / update routes gate on manage-themes; and both AI trigger surfaces (/api/v1/cms/ai/* and the AiTools Livewire component) gate on a new cms.ai.use ability. All three abilities are registered deny-by-default in their module service providers and seeded by PermissionsTableSeeder (granted to admin), so a host that never seeds them is closed rather than open. Breaking for API consumers: a plausibly-privileged but ungranted authenticated user now receives 403 from these endpoints. Installing a plugin or activating a theme runs attacker-supplied PHP, so authentication alone was insufficient.
  • Plugin ZIP extraction gained the zip-slip and single-slug guards the theme extractor already hadPluginManager::extractZip() and the plugin update/restore extraction paths now reject absolute and .. entries, require every entry to live under the derived slug directory (so a sibling top-level folder can no longer overwrite a different trusted plugin), validate the derived slug, and check extractTo()'s return value. Shared with the theme module through Core\Updates\Support\ExtensionArchive so the two cannot drift.
  • Theme and plugin archive extraction now caps the uncompressed size (cms.themes.maxUncompressedSize / cms.plugins.maxUncompressedSize, 100MB default) before extracting, closing a zip-bomb / disk-exhaustion vector the compressed-size checks did not cover.
  • The legacy plugin update_url download path now enforces https and the same checksum gate as the source-backed path — a plaintext feed is refused, and a feed advertising no digest is rejected unless cms.updates.allow_unverified_updates is set, rather than silently extracting and executing an unverified archive.
  • Admin menu label / title / menuTitle are coerced to plain strings, closing the Htmlable escaping bypass NavUrl already closed for url; and NavUrl now refuses protocol-relative //host (and /\host) URLs that navigate off-origin.
  • Re-seeding scopes the admin grant to the framework's own permission slugs rather than every permission in the table, so a re-seed no longer sweeps in third-party/consumer permissions.

Fixed

  • A caught update failure restores the pre-update snapshot before lifting maintenance mode, and honours lift_maintenance_on_interrupt — maintenance mode is no longer lifted up front, so the site never serves public traffic from a half-applied tree while the rollback (and its composer install) runs; a failure on a half-applied step keeps the site down under the default step_aware policy.
  • A pinned downgrade (--target-version with --allow-downgrade) is reachable even when the install is already on the latest release — the latest-only "no update available" gate no longer short-circuits an explicit pinned target, and update:perform no longer exits success while ignoring it.
  • A release declaring a min_php_version / min_framework_version the host does not meet is refused before any file is touched, rather than installed and only failing once the new code hits an unparseable syntax.
  • A rollback whose extraction fails partway now throws instead of reporting a clean restore.
  • The extraction-additions ledger is persisted, so a manual update:rollback in a fresh process removes the files an interrupted update added instead of leaving them orphaned alongside the restored snapshot.
  • runningUpdatePid() treats EPERM as "alive", so reconciliation no longer marks another user's healthy update run as interrupted; the no-lock fallback in acquireUpdateLock() still refuses when a live run is recorded.
  • Plugin update-check caching distinguishes "checked, no update" from "check failed" — a transient rate-limit or 5xx is no longer cached as "no update" for 12 hours — invalidates its cache after a successful update, and re-compares versions so a stale cache cannot trigger a redundant re-install.
  • Checksum comparison is normalized through one helper (Core\Updates\Support\ArchiveChecksum) across the application, theme, and plugin managers, so an uppercase or padded digest verifies instead of failing as a spurious mismatch.
  • A distinct finish-forward marker stops update:status from rendering a deliberate finish-forward as the alarming "partial update" case.
  • ThemesController::update() returns 422 keyed by slug for an unknown theme, matching activate() and the plugin module, and the admin layout <title> no longer double-escapes an inline @section('title', …).

Changed (this review pass)

  • The theme and plugin UpdateManagers share one update pipeline — the source-resolution, token, checksum, and version-comparison helpers now live in Core\Managers\Concerns\ManagesExtensionUpdates, and the two upload form requests share Core\Http\Requests\ZipUploadRequest, so a fix or hardening applied once can no longer miss the near-identical copy.
  • The application updater's slug is configurable (cms.updates.application_slug, default application) instead of a hard-coded product name, and cms.updates.current_version_config_key is now honoured.

Added

  • PerformUpdateJob and ApplicationUpdateManager::dispatchUpdate() — run the self-updater on a queue worker instead of blocking an HTTP request (#258) — 2.7.1 made an HTTP-triggered performUpdate() survivable (set_time_limit( 0 ), a shutdown guard that lifts maintenance mode, a persisted step marker); it did not make it appropriate. An inline update still occupies a PHP-FPM worker for several minutes and keeps occupying it after the caller disconnects, is subject to gateway timeouts (nginx proxy_read_timeout, load balancers, Cloudflare's 100s) and to FPM's request_terminate_timeout that no userland call can override, and gives the operator no feedback beyond polling the state file. dispatchUpdate() pushes the run onto a queue and returns, so an admin endpoint dispatches and polls updateState() rather than blocking. performUpdate() is unchanged and still supported as a direct call — the job simply calls it on a worker. php artisan update:perform --queue is the console equivalent.
  • The sync queue driver is refused rather than tolerated (#258) — dispatching to sync executes the job inline in the dispatching process, which is precisely the multi-minute blocking request queueing exists to avoid, reintroduced silently while the feature looks from the outside like it works. dispatchUpdate() inspects the resolved connection's driver and throws UpdateException::updateQueueUnusable(), naming the fix. cms.updates.queue.allow_sync opts back in for a caller that genuinely wants inline behavior. The opt-in deliberately does not rescue the null driver (which discards the job, so the site would never be updated) or an unconfigured connection — neither ever runs the update, so there is nothing to opt in to. The one failure the guard cannot detect from config is a well-configured connection with no worker consuming it; that surfaces as a record stuck at queued, which update:status names explicitly and answers with the queue:work invocation to run.
  • UpdateRunStatus::Queued, and update:status reporting it (#258) — "queued, not yet started" was previously indistinguishable from every other state. The record carries queued_at, queue_connection and queue_name, and keeps them for the whole run rather than only while it waits — "which worker is meant to be running this" stays the operative question until it finishes. A queued run exits zero (nothing has gone wrong, and nothing on the installation has changed) and prints the worker command that would move it along.
  • cms.updates.queue.* (#258) — connection, queue, timeout and allow_sync, with matching CMS_UPDATES_QUEUE_CONNECTION / CMS_UPDATES_QUEUE / CMS_UPDATES_QUEUE_TIMEOUT / CMS_UPDATES_QUEUE_ALLOW_SYNC env vars. The job's timeout derives from the updater's own phase budgets — download_timeout + composer_timeout + a 900s buffer for the steps that have no timeout of their own (backup, extraction, migrations), 1,800s with the shipped values. That timeout travels with the job and takes precedence over the worker's --timeout (Worker::timeoutForJob() falls back to the worker flag only for jobs carrying none), so a short --timeout cannot cut an update short.
  • A queue connection whose retry_after is shorter than the update is refused (#258) — retry_after is the queue's "this reserved job must have died" timer, and Laravel ships 90 seconds for database, redis and beanstalkd, which every real update outruns. Left alone the queue hands the same update to a second worker 90 seconds in, while the first is still running composer install. $tries = 1 does not save that — it is what makes it land badly: the duplicate exceeds max attempts, so the worker fails it without ever calling handle(), and it goes straight to failed() carrying a MaxAttemptsExceededException against a perfectly healthy run. dispatchUpdate() now throws UpdateException::updateQueueRetryTooShort() naming both numbers and the value to raise. Only connections carrying the setting are checked; SQS expresses the same idea as a queue-side visibility timeout that is not readable from the app.
  • Failure reconciliation only ever touches its own run (#258) — a failing job can be handed a record it does not own. performUpdate() takes its flock sentinel before its own try block, so losing that race throws updateAlreadyRunning straight past the bookkeeping and into the worker; and a retry_after redelivery arrives having done no work at all. Either would have marked the winner's live record interrupted and run artisan up on a site that was mid-extractUpdate(), serving traffic from a half-extracted tree — the exact outcome the interruption machinery exists to prevent, caused by the machinery itself. handleFailedUpdateJob() now probes the recorded PID for liveness first and leaves other processes' runs alone.
  • clearUpdateState() releases the dispatch lock (#258) — the record is a file under storage/ and the lock is a cache entry, so they can desynchronise. Clearing a stuck queued record left dispatch refusing with "an application update is already queued" while update:status reported that none had ever been recorded — escapable only by cache:clear or by waiting out the TTL, neither discoverable, and the lock key being Laravel-internal. Discarding the record is the operator's reset button, so it now actually resets.
  • UpdateException::updateAlreadyQueued() and ::updateQueueUnusable() (#258) — a second dispatch is refused loudly rather than swallowed. dispatchUpdate() takes the job's ShouldBeUnique lock itself instead of leaving it to PerformUpdateJob::dispatch(), which takes the same lock and then silently returns when it cannot get it: a double-clicked admin button would have been told the update was queued while nothing ever ran it. A queued record older than the job timeout stops blocking, so a host that dispatched before starting a worker is not wedged by its own first attempt. $tries is 1 and not configurable — a retry would restart the update at step 1 over a tree the previous attempt had already partly overwritten, which is the interleaving the flock sentinel exists to prevent, reintroduced by the queue rather than by a concurrent caller. That flock remains the real concurrency guarantee: the unique lock lives in the cache, and step 8 of the update runs cache:clear.
  • A queued update killed mid-run no longer leaves the site serving 503s (#258) — PerformUpdateJob::failed() reconciles the persisted record from the worker process, which matters most for a worker timeout: the run is killed mid-step, so performUpdate()'s catch never records the outcome and the in-process shutdown guard may never lift maintenance mode. A run killed mid-flight is stamped interrupted and maintenance mode is lifted (honoring cms.updates.lift_maintenance_on_interrupt); a job that failed before the update started is stamped failed rather than left claiming queued forever; a run that already recorded its own outcome is left alone, so the real error is never replaced by the worker's generic one.
  • UpdateCapability — Gate abilities a host application can authorize the self-updater against (#266) — cms.updates.perform, cms.updates.rollback and cms.updates.view, registered by CoreServiceProvider and denying by default. The framework still ships no HTTP or Livewire trigger for updates and every update:* command stays console-gated, but ApplicationUpdateManager is written for the HTTP case — raiseExecutionLimits() and ignore_user_abort() exist to support it, and performUpdate()'s docblock has always described the admin UI — while offering a host nothing to check. performUpdate() is by design a remote-code-execution channel: it overwrites PHP files and then runs composer install, which executes post-install-cmd scripts from the just-overwritten composer.json, so a host wiring that UI now has a name to authorize against, and the shipped default for that name is deny. Grant it by seeding an RBAC permission whose slug matches (PermissionsTableSeeder seeds all three, and the admin role receives every permission), or by defining the ability in the host's own AppServiceProvider. An ability the host has already defined is left alone. config/updates.php and docs/self-updater.md now both say plainly that the manager performs no authorization of its own and that an HTTP trigger must be gated, rate-limited and CSRF-protected. Deferred from the 2.7.1 deep review as SEC-10.
  • Themes have an update path (#278) — a new Modules\Themes\Managers\UpdateManager gives themes parity with plugins: update check, in-place install over an existing theme, and rollback on failure. Themes were previously the one extension type that could only be updated by deleting the directory by hand and re-uploading — on a repo-less host, an SFTP session. The optional update key in theme.json is spelled identically to the plugin manifest key and resolves through the same UpdateCheckerFactory / UpdateSourceInterface, so GitHub Releases, GitLab and custom-JSON sources all come along and archives are streamed to disk and checksum-verified exactly as they are for plugins. UpdateCheckerFactory already handled UpdateType::Theme and already read a theme's version out of its manifest; nothing had ever called it with that type.
  • GET /v1/themes/updates and POST /v1/themes/{slug}/update — mirroring PluginsController::checkUpdates() / ::update(), behind the same auth:sanctum group, returning updates in the same payload shape so one admin component can render both extension types. Update failures surface as a ValidationException error bag rather than a bare JSON body, so host apps using Inertia get a working error bag (#124).
  • ap.cmsFramework.theme.updating / ap.cmsFramework.theme.updated — theme update lifecycle hooks, joining the install and activation hooks from #123. updating receives ( $slug, $oldVersion, $newVersion ) and listeners may veto by throwing; updated receives ( $slug, $newVersion, $manifest ).
  • cms.themes.updateCacheTtl, cms.themes.backupPath, cms.themes.updateTokens, cms.themes.maxUpdateSize — update-check TTL, the directory an installed theme is archived to before it is replaced, per-slug access tokens for themes whose update source is a private repository, and a size ceiling for downloaded update archives. The last is deliberately separate from maxUploadSize: that one is an abuse control on the upload endpoint, and a theme shipping images and fonts clears its 10MB default easily — gating updates on it would leave such a theme permanently un-updatable, which is the exact situation this feature exists to fix.
  • ThemeUpdateException — mirrors PluginUpdateException, with factories for a failed backup, a failed swap, a theme declaring no update source, and a verbatim failure reason so an integrity failure surfaces as what it is.
  • ThemeManager::stageThemeFromZip() / ::swapStagedTheme() — extract and fully validate an update archive in themes/.updates/ before anything replaces the installed theme, then swap it into place with two rename() calls. This is what lets the active theme be updated without maintenance mode: the window during which its directory does not exist is a single syscall wide, and a bad archive is rejected before the swap is reached rather than after it has deleted a working theme. Rollback deletes the live directory outright before re-extracting the backup, so files a failed update added are removed rather than left orphaned alongside the restored ones (#272).
  • Plugins can update from GitHub Releases (#277) — a new optional update key in plugin.json declares where self-updates come from, and Modules\Plugins\Managers\UpdateManager resolves it through the same UpdateCheckerFactory / UpdateSourceInterface the application updater uses. {"update": {"github": "owner/repo"}} is shorthand for the GitHub Releases source; {"update": {"url": "https://..."}} is handed to the source detector as-is, so GitLab repositories and custom JSON endpoints fall out of the same key. Both forms are https-only. Publishing a plugin update is now git tag plus a GitHub Release, with no separately-hosted JSON feed. UpdateCheckerFactory already accepted UpdateType::Plugin and already resolved a plugin's installed version out of the plugins table — nothing had ever called it with that type.
  • cms.plugins.updateTokens — access tokens for plugins whose update source is a private repository, keyed by plugin slug. Deliberately per-slug rather than one global token: a plugin names its own update host in its own manifest, so a shared token would be handed to whatever host any installed plugin asks for. Public repositories need no entry.
  • PluginUpdateException::updateFailed() — carries a reason string, so an integrity failure surfaces as what it is instead of being collapsed into downloadFailed()'s "Failed to download update for plugin".
  • A cms view namespace, and cms::admin.layouts.app inside it (#246) — the layout Blade admin pages extend. examples/hello-world-plugin/ has extended it since it was written and grep -rn "loadViewsFrom.*'cms'" returned nothing, so the reference plugin died on No hint path defined for [cms]. The layout is deliberately plain — the framework is front-end agnostic and ships no CSS build — and does three things: renders the menu from apGetAdminMenu(), yields a content section under a title, and exposes styles / scripts stacks. Hosts replace it with their own chrome via php artisan vendor:publish --tag=cms-views, which lands in resources/views/vendor/cms/ — the path Laravel resolves ahead of the package's own, so a host swapping in real chrome requires no plugin to change the view it extends. Registered from CMSFrameworkServiceProvider rather than a module provider so the namespace exists for every consumer regardless of which modules are enabled.

Changed

  • docs/self-updater.md no longer claims that nothing serializes concurrent updates (#258) — the paragraph predated the 2.7.1 flock sentinel and contradicted the "Concurrent updates (2.7.1)" section two screens above it, telling operators to build a lock the framework already takes for them.
  • AI feature keys are read from the agent that owns them, not respelled per endpoint (#176) — the five cms.* keys were hardcoded in ten places (AiController and AiTools, five methods each) and a further five times in CMSFrameworkServiceProvider::aiFeatures(), even though every agent already declares public string $featureKey. Renaming one meant editing six-plus files with nothing to catch a miss. runAgent() / run() now take the agent class plus its input and resolve the key themselves, and aiFeatures() is derived from a new public const AI_AGENTS rather than respelling the map — so a key is spelled in exactly two places, the agent that owns it and AI_FEATURE_KEYS, with a test asserting the two agree. Purely internal: the JSON envelopes, the ap-cms-ai:{key}:{status} event names, and the aiFeatures() return shape and key ordering are all unchanged.
  • Agent metadata is read off the declared class rather than a container-resolved instance (#176) — docs/AI-Features.md invites hosts to bind a subclass over an agent, which makes construction a genuine throw site and a subclass's $featureKey a genuine source of divergence. Resolving metadata through the container would have meant two distinct faults: a failed binding escaping the very handler that builds the error envelope, so an HTTP caller loses its JSON body and a Livewire caller never receives a status event at all; and aiFeatures() keyed by the override while 'agent' still named the original class and AI_FEATURE_KEYS still listed the original key, leaving three sources disagreeing. The new Ai\Support\AgentMeta reflects declared property defaults instead — it cannot throw, and it cannot disagree with the constant. Agent construction stays inside the wrappers' try block, and new tests cover all four exception categories on both trigger surfaces plus the construction-failure path.
  • cms.themes.default no longer names a specific consumer's theme (#125) — breaking for anyone relying on the implicit fallback. The shipped config defaulted to 'digital-shopfront', and ThemeManager::getActiveTheme(), ThemeManager::markActiveTheme() and ThemesServiceProvider::boot() each repeated that literal as the third argument to their config() call, so removing or nulling the config key changed nothing — the slug was hard-coded in three more places than the one you could edit. The framework bundles no themes, so a consumer shipping a differently-named default got a silent lookup for a theme that does not exist: getActiveTheme() returning null for a reason that had nothing to do with their install. The config key is now env( 'CMS_DEFAULT_THEME' ) and all three literals are gone. Unconfigured, "no theme is active yet" is an explicit state rather than a missed lookup — getActiveTheme() returns null, registerThemeViewPath() early-returns and leaves the host's view paths alone, markActiveTheme() flags every discovered theme inactive, and the site-editor resolvers take their existing theme-less paths. The registered themes.activeTheme setting default tracks the same config value, because SettingsManager::getSetting() falls through to it once the caller's own default is null — leaving a literal there would have reinstated the fallback the config removed. To keep the old behavior, set CMS_DEFAULT_THEME=digital-shopfront (or activate a theme explicitly, which consumers that call activateTheme() already do — for them nothing changes, since a stored themes.activeTheme has always won over the default). Same family of fix as #120.
  • Themes and Plugins action endpoints fail as a ValidationException, so Inertia's error bag works (#124) — every failing action on POST /v1/themes, POST /v1/themes/{slug}/activate, POST /api/v1/plugins/install, and the plugin activate / deactivate / update / destroy endpoints now returns 422 carrying Laravel's {"message": "...", "errors": {"field": ["..."]}} shape, keyed by the field the failure belongs to — theme_zip / plugin_zip for the uploads, slug for the actions taken against an installed extension. The previous bodies were message-only, which Inertia lands as a generic exception rather than populating usePage().props.errors and useForm().errors, so an admin UI could not render field-level messages without forking the controllers. Pure-API consumers gain a parseable errors object and keep the message they already read. New UploadThemeRequest and InstallPluginRequest form requests front-load the upload rules — presence, ZIP MIME type, and the configured size ceiling — so those failures arrive in the same shape as the manager-level rejections they sit in front of. IncompatiblePluginException deliberately keeps its 409 and its structured code / required_version / host_version payload, which a flat error bag cannot express. The 422 covers request validation and the managers' own named rejections; an unexpected fault is reported and returns 500 on every Themes endpoint rather than being dressed up as a field error — ThemesController::update() was converting those into a 422 since 2.8.0 and now matches upload() and activate(). The Plugins endpoints raise the same generic 422 they always have on an unexpected fault, but no longer echo the raw exception message into it: the detail goes to report() instead of to the client, so a database, filesystem, or host-hook failure can no longer disclose internal paths through an endpoint reachable by any authenticated user.
  • POST /api/v1/plugins/{slug}/update distinguishes "already up to date" from "updated" (#124) — UpdateManager::updatePlugin() returns false when no update is available, which the controller discarded, reporting "Plugin updated successfully" for a no-op. The response now carries an updated boolean alongside the matching message, the same shape POST /v1/themes/{slug}/update already returned.
  • POST /v1/themes/{slug}/activate answers an unknown slug with 422 instead of 404 (#124) — breaking for consumers branching on the status. The slug is form input on this endpoint, not a resource path: an admin activating a theme from a list that has drifted out of sync with the themes directory should see a field error on the control they used, not a hard error page. GET /v1/themes/{slug} is unaffected and still answers 404, because there the slug is the resource path.
  • The update manifest key is validated by one shared implementation (#278) — the rules moved from PluginManager into HasManifestParsing, which both managers already use, so plugins and themes cannot drift on a key they deliberately spell identically. Each manager still raises its own exception type. No behavior change for plugins; the messages are byte-identical.
  • Source-backed plugin updates stream to disk and are checksum-verified (#277) — a plugin using the new update key downloads through its update source, which streams the archive via StreamsDownloadsToDisk rather than buffering the whole ZIP in memory (the OOM shape fixed for the core updater in #214 / #216 / #219) and enforces https across the initial request and every redirect. The archive is then verified against the SHA-256 the release advertises — a {asset}.zip.sha256 sidecar, or a SHA-256: line in the release body — honoring cms.updates.verify_checksum and cms.updates.allow_unverified_updates exactly as ApplicationUpdateManager does. With the shipped defaults a release publishing neither is refused; see #271 for the publishing-side workflow change that attaches the sidecar.
  • Plugin update checks normalize on UpdateInfo internally, which is what makes sha256 reachable at all. The payload at GET /api/v1/plugins/updates is unchanged: source-backed results are flattened onto the same version / download_url keys the endpoint has always returned, with sha256, changelog, release_date, file_size and metadata added alongside. Plugins declaring only the legacy update_url keep the existing custom-JSON behavior verbatim — raw feed payload as the response body, no checksum enforcement.
  • UpdateChecker no longer evicts plugin and theme cache entries using app.version — the staleness heuristic added in 2.5.3 compares a cached currentVersion against the host application's version, which for a plugin is a different number by construction. Every plugin cache entry was therefore stale on its first read, so the cache was written and never served. The heuristic now applies only to UpdateType::Application.

Deprecated

Removed

Security

  • The admin menu's URL allow-list is enforced where it can no longer be walked around (#246) — three gaps in sanitizeExternalUrl()'s coverage, all latent while every consumer rendered the menu itself and all reachable the moment this release ships cms::admin.partials.menu, which puts url into an <a href> inside the package. Blade's escaping does not stop a javascript: scheme, so each one executed attacker JS in an authenticated admin session on click.

    • The filter ran after sanitization. decorateItem() checks a pre-set url, but decoration happens before ap.cmsFramework.admin.menu, so a row a subscriber injects had never been checked — and the documented injection shape, a bare array with a url and no route, also takes decorateItem()'s early return. getAdminMenu() now runs the post-filter tree through a recursive sanitizeMenuUrls() pass covering items and subItems as well as top-level rows. The pass is idempotent, so URLs already sanitized during decoration are unaffected, and registerNavEntry()'s ingress check is unchanged.
    • The scheme regex could be stepped over. trim() only touches the ends, so java&#9;script:alert(1) failed ^[a-zA-Z][a-zA-Z0-9+.\-]*:, fell through the allow-list as a "relative reference", and survived htmlspecialchars() — which leaves tabs, newlines and C0 controls alone — to reach the browser, whose URL parser strips exactly those characters before resolving the scheme. Same for a leading \x01. The value is now normalized the way the URL parser normalizes it (tab/CR/LF removed anywhere, leading and trailing C0 and spaces stripped) before the scheme check, and the normalized form is what is returned, so the checked value and the rendered value cannot diverge.
    • A non-string url skipped both layers. The guard was is_string(), and Laravel's e() returns an HtmlableHtmlString among them — unescaped, so 'url' => new HtmlString( 'javascript:alert(1)' ) missed the allow-list and the escaping. Values now go through NavUrl::sanitizeValue(), which resolves a Stringable before checking it; anything not stringable becomes '#'.

    Relatedly, PluginServiceProvider::normalizeNavUrl() now takes mixed rather than string. registerNavEntry() receives a plugin-supplied array and the file declares strict_types=1, so a url that was anything but a string — an HtmlString, an int, an accidental array — raised a TypeError at the parameter boundary inside the plugin's boot(): the same whole-application failure mode as the route-action bug above, from a plugin typo. Those values now take the documented '#' fallback. Widening the parameter is a signature change on a protected method of an abstract base class, so a plugin that overrides normalizeNavUrl( string $url ) — no reason to, it is an internal sanitizer, but the method is part of the extension surface — must widen its own parameter to match.

    The rules themselves moved into a new Modules\Admin\Support\NavUrl, because they existed twice — once on the render path in AdminMenuManager::sanitizeExternalUrl(), once on the registry ingress path in PluginServiceProvider::normalizeNavUrl() — with the same regex and the same scheme list. That duplication is precisely how one copy came to be hardened while the other kept writing the un-normalized value into PluginRegistry, which is a public surface in its own right: PluginRegistry::navEntries() and Plugin::getNavEntriesAttribute() are read directly, not only through getAdminMenu(). Both now delegate, so the next change to the allow-list is one edit. Behavior for every URL form the allow-list already accepted — http(s), mailto:, tel:, /-relative, #-fragment, and scheme-less relative paths — is unchanged, and pinned by a dataset.

  • Plugin update sources are re-checked for https at the point of use, not only during install-time manifest validation. UpdateManager::updatePlugin() refreshes a plugin's meta straight from the manifest inside the downloaded ZIP and never re-runs PluginManager::validateManifest(), so an update can seat an update value that never passed validation. A plaintext source is not cosmetic there: CustomJsonUpdateSource would fetch the update metadata over http, and a network attacker rewriting that response chooses both the download URL and the sha256 it is checked against — digest and archive come from the same document, so verification would confirm the attacker's own archive, which is then extracted into plugins/ and executed as PHP. A non-https source is now ignored with a warning instead of being fetched.

Fixed

  • The lock-sync check no longer aborts updates composer would have installed (#264) — 2.7.1's verifyComposerFilesInSync() (#255) treated any content-hash divergence between composer.json and composer.lock as fatal, aborting at step 6 and triggering a full backup rollback. But composer install installs from the lock despite a stale hash — it only warns — and hard-fails solely when the lock cannot satisfy composer.json (a required package missing from the lock, or a constraint it violates), confirmed against composer 2.10.1 (Installer.php/Locker.php). So the check converted a composer warning into a full-rollback abort for updates composer would have completed: a release that changed only extra, version, or repositories while shipping an old-but-satisfying lock hit it. The pre-emptive abort is removed. The check is now a diagnostic: on a detected divergence it logs a warning and lets composer adjudicate, and only when composer itself fails does UpdateException::composerInstallFailed wrap composer's output with the framework's accurate "this is not a merge conflict — the release likely shipped no matching composer.lock" explanation, replacing composer's misleading "incorrectly merged or manually edited" guess. The valuable half of #255 is kept; the overreach that could abort a real host's update is dropped. UpdateException::composerFilesOutOfSync (unused after this change) is removed; verify_composer_lock_sync = false still switches the diagnostic off entirely.

  • A plugin admin page registered with a Blade view renders instead of throwing Invalid route action (#246) — PluginServiceProvider::registerAdminPage() passed $config['view'] straight through as the page's action, and AdminPageManager::registerRoutes() hands that to Route::get( $slug, $action ). Laravel accepts a closure, a controller class, a Class@method string or an array there — a Blade view name is none of them, so it was read as an invokable controller class and rejected. The flavor had never worked, and the blast radius was the whole application rather than the plugin's own page: registerRoutes() runs inside AdminServiceProvider's $this->app->booted() callback, so the exception fired before routing and 500'd every URL, admin or not, for any host with such a plugin active — the bundled hello-world example among them. The view name is now wrapped in a closure that renders it, with the route's own parameters passed through as view data so a page registered at reports/{id} can read $id. Closure actions stay route:cache-safe — Laravel serializes them via SerializableClosure. docs/admin/Menu-and-Pages.md described action as accepting a "view response", which is the same misreading one layer down: the raw apAddAdminPage() helper does pass action to Route::get() unchanged, so a caller using it directly must wrap a view themselves, and the doc now says so and shows the wrap. A component is still passed through verbatim and therefore still throws — how a host mounts a federated page is a design question this fix deliberately does not answer, so docs/plugin-authoring.md and the example plugin's README now carry an explicit warning not to use that flavor yet, where they previously advertised it as working. Tracked as #296, along with the neither-view-nor-component case, which throws the same way.

  • The bundled admin layout honors showInMenu => false (#246) — addSubPage() has always stored the flag and docs/admin/Menu-and-Pages.md has always documented it as "routed but hidden from menu", but nothing in getAdminMenu() ever read it back; it was live only to the extent that each consumer's own renderer checked it. Shipping cms::admin.partials.menu in this release would have made the framework the thing that ignores it, putting a link to posts/edit in the sidebar. The renderer filters on it. getAdminMenu()'s payload is deliberately unchanged — hosts reading the flag themselves keep receiving those rows.

  • cms-framework-config is a real publish tag, so the documented install step publishes something (#290) — README.md has always told consumers to run php artisan vendor:publish --tag=cms-framework-config, and that tag was registered nowhere in src/. vendor:publish exits 0 on a tag matching nothing, so following the README produced an empty config/, no framework config files, and no indication anything had gone wrong — the failure was indistinguishable from success. Each of the four config files is now tagged twice: under its existing module-only tag (artisanpack-package-config, cms-themes-config, cms-plugins-config, cms-updates-config), and under the umbrella cms-framework-config the README documents, so the one-liner publishes all four and a consumer who wants one module's config in isolation keeps that option. The umbrella tag is also the narrowest way to ask for this package's config specifically: artisanpack-package-config is an ecosystem-wide convention shared by every ArtisanPack UI package, so in a host with several of them installed it publishes far more than the CMS framework's file. No existing tag changed, so nothing that already worked breaks. docs/Installation-Guide.md and docs/Configuration.md were separately instructing --tag="config", which was also never registered, and both named the main config file config/cms-framework.php when it publishes to config/artisanpack/cms-framework.php; both are corrected. docs/themes.md located the theme settings at config/cms.php under a themes key and showed the file wrapped in that key — the published path is config/cms/themes.php and it returns the settings array directly, since the module merges it under cms.themes — which is what made #125's new "edit the published config directly" guidance unreachable in two different ways at once. A new ConfigPublishTagsTest asserts every source file is reachable through both its tags and lands at the documented destination, and that every --tag= the README names is actually registered, so this particular drift fails at test time rather than silently at install time.

  • comments.form.action is aliased to ap.cmsFramework.comments.form.action, completing a wave 4b rename that never landed (#245) — the 2.5.0 wave 4b table (#194) listed this filter as renamed, and the CHANGELOG entry below says so, but no entry was ever added to HookAliases::wave4bLifecycle(). The rename was therefore documentation-only: the new name resolved to nothing, and a host app that renamed its subscriber on the strength of the CHANGELOG would have silently stopped receiving the filter. Downstream apps had to stay on the un-prefixed name. The alias now exists and resolves in both directions, so a host may subscribe under either name. This filter's only fire site is in artisanpack-ui/visual-editor (the post-comments-form block), not in this package — it is namespaced here because comments are this package's domain and the filter's default value is this package's POST /api/v1/comments endpoint, the same emitter-is-not-owner split as ap.rbac.roleRegistered. visual-editor declares the identical alias on its own side, so the old name keeps resolving in installs that do not have the CMS Framework; because the alias is registered on both sides, a host may rename its subscriber to ap.cmsFramework.comments.form.action immediately, without waiting for the visual-editor release that switches the fire site over.

  • The release workflow publishes a release archive and a SHA-256 sidecar, so a GitHub-sourced host can self-update with the shipped defaults (#271) — 2.7.1 taught GitHubUpdateSource to discover a checksum, but release.yml attached no assets for it to discover, so releases carried only GitHub's auto-generated zipball. The zipball has no asset name, extractChecksumFromSidecar() refuses to correlate a digest against an unnamed target, and the CHANGELOG-derived notes carry no SHA-256: marker — so sha256 stayed null and, with verify_checksum = true and allow_unverified_updates = false, every GitHub-sourced update was refused. The release job now builds cms-framework-{version}.zip with git archive — whose bytes are stable for a given tree, unlike the zipball's — writes cms-framework-{version}.zip.sha256 beside it, and attaches both. The sidecar name is the exact string the source correlates on, so a new ReleaseWorkflowTest asserts the two sides still agree rather than leaving the next rename to fail closed at tag time. The job also refuses to publish an archive missing composer.lock, which the updater installs from rather than re-resolving (#255).

  • A plugin update that fails while taking its backup no longer trips an undefined-variable errorupdatePlugin() referenced $backupPath from its catch blocks, but the variable is assigned by the first statement of the try. When backupPlugin() itself threw, the recovery path died on the undefined variable instead of reporting the backup failure. restoreFromBackup() now takes a nullable path and returns early when there is no backup to restore from, rather than deleting a working install it has nothing to replace.