Skip to content

v2.9.0

Choose a tag to compare

@github-actions github-actions released this 22 Aug 02:33
· 18 commits to main since this release
Immutable release. Only release title and notes can be modified.
2ca262b

Security

  • The user resource endpoints now enforce authorization, not just authenticationUserController's index / store / show / update / destroy carried no authorization check at all, so any authenticated user could list, create, edit (including resetting another user's password), or delete any user. A new UserPolicy — registered against the host-configured user model and mirroring RolePolicy — now gates all five actions on the users.manage capability (users.delete for deletion), the same capabilities the bulk endpoint already enforces. Breaking for API consumers: a plausibly-privileged but ungranted authenticated user now receives 403 from these endpoints, and a guest 401.

  • The notification-table rename migration no longer risks renaming Laravel's own notifications table — the migration guarded only on table presence, so an install that had worked around the 2.8 collision (create migrations recorded as run, cms_ tables absent, Laravel's own notifications present) would rename Laravel's table into cms_notifications, breaking the CMS model and stranding Laravel's database-channel data. The rename now shape-sniffs the ambiguous notifications name and only renames it when it carries the CMS columns (send_email present, notifiable_type absent).

  • The CollectionEditor and FieldBuilder Livewire components are hardened against property tampering — their typeId is now #[Locked], so a client cannot rewrite it between requests to operate on a type it was never authorized for. CollectionEditor::save() now authorizes creates with the type-aware createForType gate (matching the REST controller) and re-checks isCollection() and the record/type match; FieldBuilder::save() now authorizes creation with the create gate (previously only mount() did, so an edit-mode mount that nulled typeId could create types unauthorized) and validates the slug's uniqueness (create) and immutability (update) the way DynamicContentTypeRequest does.

  • Admin pages registered with an empty or missing capability now fall back to the access_admin_dashboard baseline instead of registering an auth-only route any authenticated user could reach.

  • The single-notification endpoint no longer leaks id existenceGET /api/v1/notifications/{id} returned 403 for a notification that exists but belongs to another user and 404 for one that does not, an enumeration oracle. It now returns 404 in both cases, routing the ownership decision through the registered view policy.

  • Bumped the transitive league/commonmark dependency to >=2.10.0, resolving 6 advisories that affected <2.9.0 — including a high-severity quadratic-time denial of service when parsing crafted Markdown (CVE-2026-71488) and an unsafe-link filter bypass (CVE-2026-71478). Laravel already permits the newer release (^2.8.1), so only the lock file changed.

  • MenuItemRequest now fails closed on the menu / parent existence rules when no theme is active, instead of widening to an unscoped exists (#291) — with no active theme, menuExistsRule() and parentExistsRule() previously fell back to a bare exists:menus,id / exists:menu_items,id, accepting ids from any theme where the scoped rule would reject them. MenuItemsController failed closed regardless (409 before reading the payload, and themeScopedQuery() returns null), so no cross-theme record was ever read or written; what remained was a weak enumeration oracle — a 422 (id exists nowhere) was distinguishable from a 409 (id exists in some theme, but none is active). Both rules now scope to a never-matching whereRaw('1 = 0') on a null theme, so the request states the same invariant as the controller and a future refactor that moves the controller guard cannot silently turn the hardening gap into a real cross-theme IDOR. Observable change: posting a menu item while no theme is active now returns 422 instead of 409 — both 4xx refusals of the same request; no successful call changes behavior.

  • Plugin updates now re-run the full manifest validation, closing the bypass where every install-time security check was skipped for the rest of a plugin's life (#283) — UpdateManager::updatePlugin() wrote the manifest from the downloaded ZIP straight into plugins.meta without re-running PluginManager::validateManifest(), so an update could seat values that would have been refused at install: a migrations_path traversal like ../../database/migrations (which runMigrations() resolves against the plugin directory and could use to re-run or roll back framework migrations), an unprefixed permissions entry like manage_users (which could wipe framework-owned permission rows on uninstall), or a malformed min_host_version / federated_module / nav_entries / update value reaching consumers that assume the validated shape. The update path now re-validates the extracted manifest (via the new public PluginManager::assertManifestValid()) before seating it; a rejected manifest unwinds through the existing backup-restore path — old files and database row restored — and surfaces as a PluginUpdateException carrying the validation reason. Breaking: a plugin currently shipping a manifest that would fail validation updates fine today and will now fail at update time on the production host. Republish such a plugin with a compliant plugin.json before updating.

Fixed

  • NotificationPolicy no longer fatals on a host User model without the RBAC traitcreate() and delete() called $user->hasCapability() unguarded, and the policy is registered globally, so a plain User model reaching those abilities (via @can, Gate::any, or a direct check) hit Call to undefined method — the same failure class #280 fixed elsewhere. Both methods now guard with method_exists() and deny cleanly.
  • Notification::$pivot no longer returns null when the notification was loaded through the inverse relation — the accessor checked the (unloaded) users relation and ignored the pivot Eloquent had actually loaded on $user->systemNotifications, so the documented $notification->pivot->is_read pattern read a property on null. It now returns the loaded pivot when present.
  • cms:plugins:sync no longer crashes on a plugin directory whose name is not a valid slug — such a directory raised PluginNotFoundException, which the sync loop did not catch, so one bad directory aborted the whole command with a stack trace. It is now caught and reported as a single failed row while the remaining plugins sync.
  • A plugin update that fails to reactivate on a new dependency/conflict is now reported as such, not as a download failure — a new manifest adding an unsatisfiable requires/conflicts threw DependencyNotSatisfiedException / PluginConflictException from the step-7 reactivation, which fell into the generic catch and surfaced as downloadFailed. It now rolls back (files, row, and is_active) and surfaces the dependency reason.
  • POST /plugins/{slug}/update now returns a structured 409 when the new version requires a newer hostUpdateManager rethrows IncompatiblePluginException deliberately, but the controller swallowed it into a generic 422; it now renders the same plugin_incompatible 409 payload activate() does.
  • Plugin delete/update filesystem paths are built from the trusted database slug rather than the raw route parameter, matching the hardening getPlugin() already applied.
  • A plugin ZIP whose plugin.json is missing or unparseable is rejected cleanlyinstallFromZip() passed a null manifest into a typed array parameter (a TypeError) and left the extracted directory orphaned; it now deletes the extraction and throws a clear PluginValidationException.
  • Boot-time plugin loading catches Throwable, not just Exception — a plugin whose service-provider class is missing raised an Error at boot that took the whole site down; it is now logged and skipped.
  • The plugin dependency read endpoints are more robustcheck-dependencies bounds the batch to 100 slugs and reports each slug's installed status (an uninstalled slug is no longer silently reported as satisfied); GET {slug}/dependencies no longer 404s a DB-registered plugin whose files are gone, sourcing its declared dependencies from the graph.
  • Notification tables no longer collide with Laravel's own database notification storage (#281) — the module claimed the table name notifications, which is the name Laravel's built-in database notification channel uses, and the two schemas are irreconcilable (Laravel expects a UUID primary key, notifiable_type/notifiable_id, a data JSON column, and read_at; none of which the framework's table has). Any host package reaching for the database channel failed on insert with a QueryException deep in the notification pipeline. The three tables now carry a cms_ prefix — notificationscms_notifications, notification_usercms_notification_user, notification_preferencescms_notification_preferences — leaving notifications free for Laravel. Breaking: a new migration renames the tables in place on existing installs (data is preserved); any application code that queried the old table names directly (rather than through the Notification / NotificationPreference models) must be updated. Fresh installs are unaffected.
  • apSendNotification() / apSendNotificationByRole() no longer fatal when the host User model lacks the notifications traits (#280) — preference filtering runs on every send and previously called notificationPreferences() unconditionally, so a User model without HasNotifications hit an uncaught Call to undefined method even on a plain send with no roles involved; the by-role helper had the same failure against roles() without HasRolesAndPermissions. NotificationManager now guards both relationships with method_exists(): a model without notificationPreferences() treats every existing recipient as opted in (send succeeds, email path included), and a model without roles() matches no users so sendNotificationByRole() returns null rather than throwing. The notifications docs now state both trait requirements.
  • A failed plugin update no longer leaves a previously-active plugin disabledUpdateManager::updatePlugin() deactivates before swapping files, but the rollback paths (failed download, extraction, or reactivation) restored the version, manifest, and service provider without restoring is_active. Because the deactivation ran on a separate model instance, the in-memory row's is_active was stale and save() never wrote it back. The revert now refreshes the row and explicitly restores the pre-update activation state.
  • registerAdminPage()'s federated component flavor now mounts a real admin page instead of 500ing (#296) — the interim fix in #246 stopped the bare component identifier from reaching Route::get() (an invalid route action that, because admin routes register from a booted() callback, once 500'd every request, public pages included) but left it rendering a chrome-less <div>. A component-only page now renders the framework-owned cms::admin.layouts.federated shell — a mount point (<div data-cms-federated-module="…">) inside the admin chrome for the host's Module Federation runtime to hydrate — and a federation host that mounts components its own way overrides the default through the new ap.cmsFramework.admin.federatedPageAction filter (passed the default action, the component identifier, and the page config; a non-closure return falls back to the shipped shell). A page declaring neither a view nor a component still responds 501 on its own route rather than breaking route registration.

Added

  • Plugin dependency management (#45) — plugins can now declare hard dependencies on, and conflicts with, other plugins via a requires.plugins map and a conflicts map in plugin.json, each keyed by plugin slug to a semver constraint. PluginManager::activate() gates on them before any state mutation: activation is refused when a required plugin is missing, inactive, or fails its version constraint (DependencyNotSatisfiedException), or when a declared conflict is installed within range (PluginConflictException). deactivate() refuses to disable a plugin while active plugins still depend on it (deactivate( $slug, force: true ) bypasses the guard; deletion and in-place updates force past it). Conflicts are enforced symmetrically, so they cannot be bypassed by activation order, and active plugins are loaded dependencies-first at boot so a dependent's service provider never boots before the provider it consumes. New public helpers checkDependencies(), getDependents(), canDeactivate(), and getActivationOrder() (a dependency-first topological sort that throws CircularDependencyException on a cycle) back three new read endpoints — GET /api/v1/plugins/{slug}/dependencies, GET /api/v1/plugins/{slug}/dependents, and POST /api/v1/plugins/check-dependencies. The resolution logic lives in a database-free DependencyResolver and reuses composer/semver for constraint matching. Conflicts are scoped to installed (not merely active) plugins — Composer-style semantics documented in docs/plugin-authoring.md. Motivated by splitting a monolithic Google integration into a reusable google-oauth plugin plus a dependent google-web-tools plugin.
  • Disk → database plugin promotion (#298) — PluginManager::installFromDisk() and syncFromDisk(), and the cms:plugins:sync artisan command, register plugins that were scaffolded directly on disk (rather than installed from a ZIP) into the plugins table so they can be activated. Sync re-runs the full manifest validation and the directory/manifest slug-match guard for every discovered plugin, reports a per-plugin installed / updated / unchanged / failed result, and continues past a single failure. Console-only — there is no HTTP route.
  • Blade theme-file fallback for templates and template parts (#126) — the template / template-part resolvers now resolve a .blade.php theme file when no block-grammar .html file exists, exposed through new is_blade / editable fields on TemplateResource and ResolvedEntity::toArray(). Blade-backed entities are metadata-only (never read or rendered by the resolver) and read-only in the site editor: a write to a Blade-backed slug is rejected with a 422. customTemplates emits a Blade-shadowed warning.
  • author_name on discoverPlugins() / getPlugin() (#297) — a normalized author name string derived from the manifest author value (plain string or { name, email?, url? } object). It is shape-normalized, not escaped — escape it on output. Typed in resources/types/plugins.d.ts.
  • Install-time manifest-slug identity guard (#315) — installFromZip() and installFromDisk() now reject a manifest whose slug differs from the extracted/directory slug, mirroring the update-path guard added under #283. Without it, a plugin could declare — and later, on uninstall, remove — permission rows namespaced under another plugin's slug. Breaking: a plugin ZIP that installed before and carries a mismatched manifest slug now fails at install.
  • The ArtisanPackUI.Security.ValidatedSanitizedInput PHPCS sniff is re-enabled (#301) — every request-input read is now either validated/sanitized or carries a reviewed phpcs:ignore with a rationale, and the Livewire type/collection editors validate their payloads against the same shape as the REST form requests.
  • TypeScript types for the plugin dependency APIresources/types/plugins.d.ts gains PluginManifestRequires, PluginDependencyStatus (with PluginVersionMismatch / PluginConflictEntry), and response interfaces for the three dependency endpoints.

Changed

  • User-facing strings in NotificationController and the Blade write-rejection responses are now translatable — the notification action messages (via __() / trans_choice()) and the TemplatesController / TemplatePartsController rejectIfBlade() messages are wrapped for i18n.
  • The generated OpenAPI spec now reports the release version (2.9.0) — the openapi.info.version config was refreshed, and its fallback now derives the version from the package composer.json instead of a hardcoded literal that had lagged at 2.5.1.