fix(34): WR-10 align the marketplace compatibility marker with plugin:install

With a framework version that is not a release (a devel build, an empty
FrameworkVersion) every plugin with a minimum was marked as needing a
newer framework, and Compatible looked only at the newest release, so a
plugin whose older release fits was marked although plugin:install
would install that release. An unknown framework now gives no marker
(and no needs-framework note on the Installed tab), and a plugin whose
newest release is too new is looked up (cached, four at a time) and
counts as compatible when an older release fits.
This commit is contained in:
Jakub Zych
2026-10-29 22:12:54 +01:00
parent 7b193032c8
commit 754ab5423a
4 changed files with 96 additions and 8 deletions

View File

@@ -186,9 +186,9 @@ Every registry call goes through `kiosk.Client`, which uses `fetchguard` with a
The admin's Settings → System → Plugins screen shows administrators holding `backend.view_plugins` (`cabana.PermissionViewPlugins`) what the application was built with and what the registry offers. The screen changes nothing: plugins are installed and updated with the commands above, followed by `summer build` and a deploy of the new binary, and the screen shows those commands with a copy button. See [Admin SPA](../backend/admin-spa.md#plugins-screen).
The Installed tab reads `party.Installed` through `GET <prefix>/api/v1/plugins` (`cabana.PluginsOverview`) and lists every plugin with its name, description, version and source. An application built before this metadata existed lists only the plugin IDs. For each plugin with a version the server asks the registry for its releases and applies the rule `summer plugin:update` uses: the newest release whose framework minimum the built framework version satisfies (`kiosk.NewestCompatible`) is an update when it is newer than the installed version (`kiosk.Newer`): a submodule version such as `v0.1.1-3-gabc1234` compares by its base tag (`kiosk.BaseVersion`), and a module at a pseudo-version by the release it builds on. Such a plugin gets an update badge and the command `summer plugin:update <id>`. A newer release that needs a newer framework is named with the framework version it needs. A plugin the registry does not list, and a plugin that is part of the application (no version), gets no badge.
The Installed tab reads `party.Installed` through `GET <prefix>/api/v1/plugins` (`cabana.PluginsOverview`) and lists every plugin with its name, description, version and source. An application built before this metadata existed lists only the plugin IDs. For each plugin with a version the server asks the registry for its releases and applies the rule `summer plugin:update` uses: the newest release whose framework minimum the built framework version satisfies (`kiosk.NewestCompatible`) is an update when it is newer than the installed version (`kiosk.Newer`): a submodule version such as `v0.1.1-3-gabc1234` compares by its base tag (`kiosk.BaseVersion`), and a module at a pseudo-version by the release it builds on. Such a plugin gets an update badge and the command `summer plugin:update <id>`. A newer release that needs a newer framework is named with the framework version it needs, unless the built framework version is not a release (a development build) and cannot be judged. A plugin the registry does not list, and a plugin that is part of the application (no version), gets no badge.
The Marketplace tab searches the registry through `GET <prefix>/api/v1/plugins/marketplace?q=&category=` (`cabana.MarketplaceResponse`): the search text (at most 100 characters) and the category are passed on to the registry. Each result shows its name, description, newest version and icon, a marker when the application already has the plugin or when the built framework is too old for its newest release, and the command `summer plugin:install <id>` with a copy button. There is no install button.
The Marketplace tab searches the registry through `GET <prefix>/api/v1/plugins/marketplace?q=&category=` (`cabana.MarketplaceResponse`): the search text (at most 100 characters) and the category are passed on to the registry. Each result shows its name, description, newest version and icon, a marker when the application already has the plugin or when the built framework is too old for every release of it (a plugin whose newest release needs a newer framework but whose older release fits gets no marker, since `summer plugin:install` installs that one; a development build whose framework version is not a release gets no marker either), and the command `summer plugin:install <id>` with a copy button. There is no install button.
The SPA never calls the registry: cabana does, through `kiosk.Client`, with a 4 second timeout per call, at most four version lookups at a time, and an in-process cache (searches for 5 minutes, at most 256 distinct searches at a time, a plugin's releases for 15 minutes, failures for 1 minute). When the registry does not answer in time or answers with an error, the Installed tab still lists every plugin, without update information, under a notice that the registry is unavailable, and the Marketplace tab shows the same notice. With `plugins.registry_url` set to an empty value no request leaves the process, the Marketplace tab and the update badges are hidden, and the Installed tab works as before.

View File

@@ -57,7 +57,7 @@ Use cases:
- Roles screen: Settings → System → Roles (controller `cabana.RolesControllerID`, permission `cabana.PermissionManageRoles`) lists, creates, edits and deletes backend roles with name, code (slugged from the name), description and a checkbox permission editor. System roles (`is_system`, or a code that code-declared `pact.Permission` grants attach to) keep their code and cannot be deleted, a role any administrator holds (deleted ones included) cannot be deleted, a code another role uses is refused, and a non-superuser editor cannot add or remove a permission they lack. The update form's Administrators tab lists the role's holders read-only (a form partial): superusers only for superuser editors, deleted and deactivated holders marked, the first 100 with a note naming the total, each login linked to the Administrators preview for an editor holding `cabana.PermissionManageUsers`; roles are assigned only on the Administrators form.
- Record actions on another plugin's controller: a plugin implementing `pact.HasAdminActionExtensions` adds `pact.AdminRecordAction` entries to any admin controller by its ID (`pact.AdminActionExtension`), its own, another plugin's or the admin's own Administrators screen (`cabana.AdministratorsControllerID`). They are merged at boot after the target's own record actions, in plugin activation order, offered on the target's preview screen and run through the same row-locked record action route; the target's `RequiredPermissions` and the action's own `Permissions` both apply. Contributions targeting `cabana.AdministratorsControllerID` run `guardOutranked` before `Run` (403 `user.outranked`, `Run` not called). An unknown controller ID, a target without a preview block, a name the target already registers (the owner's or an earlier contribution), a reserved or non-identifier name, a missing `Run` or an empty label stops the boot. Extensions are compiled only when the admin is active.
- Form fields on another plugin's controller: a `pact.AdminActionExtension` with `Form` set to a `pact.AdminFormExtension` places fields and tabs in any admin controller's form by its ID. Its fields document is the `fields.yaml` format plus `before:`/`after:` anchors, `zone:` (only the tab zone for now) and a `tabs:` list, compiled at boot with every `fields.yaml` check; extensions apply in plugin activation order and the form is sent in the compiled order, so the SPA adds no ordering. `Hide` removes owner fields, `Permissions` and `Visible` decide per request who sees the fields (schema, show and save alike), widget fields run the extension's `Actions` and partial fields render from its `Dir` through `PartialData`. Extension values leave the save body before any owner path reads them; `Save` runs in the record's save transaction after the owner row is written (`pact.AdminFormErrors` answers 422, its notices join `meta.notices`) and `Load` serves the values on show. A form no extension lays out compiles unchanged. Unknown anchors, duplicate names, anchor cycles, anchors outside `Requires()`, other zones, `fileupload` fields and clashing widget action or partial names stop the boot naming the plugin, the controller and the name.
- Plugins screen: Settings → System → Plugins opens the admin SPA route `/system/plugins` (the settings entry carries `path` instead of a controller) for administrators holding `cabana.PermissionViewPlugins` (`backend.view_plugins`, granted to no role in code; superusers always pass). GET `/plugins` lists the plugins the application was built with from the `party.Installed` metadata `summer build` bakes (name, description, version, source, module and package), or only the IDs of `party.Activated` for an application built before that metadata existed, with the framework version and the state of the plugin registry (`plugins.registry_url`, read through `kiosk.RegistryURL`). With the registry configured, cabana (never the SPA) looks up each versioned plugin's releases through `kiosk.Client` and marks an update when `kiosk.NewestCompatible` finds a release newer than the installed version by `kiosk.Newer` (a `git describe` string or a pseudo-version compares by the tag it builds on), with the `summer plugin:update <id>` command; GET `/plugins/marketplace` searches the registry and adds installed and compatibility markers and the `summer plugin:install <id>` command. Registry calls have a 4 second timeout, at most four run at once, and answers are cached in process (searches 5 minutes, at most 256 distinct searches with expired and then the oldest dropped first, releases 15 minutes, failures 1 minute); a failing registry gives `status` unavailable and never a non-200 answer.
- Plugins screen: Settings → System → Plugins opens the admin SPA route `/system/plugins` (the settings entry carries `path` instead of a controller) for administrators holding `cabana.PermissionViewPlugins` (`backend.view_plugins`, granted to no role in code; superusers always pass). GET `/plugins` lists the plugins the application was built with from the `party.Installed` metadata `summer build` bakes (name, description, version, source, module and package), or only the IDs of `party.Activated` for an application built before that metadata existed, with the framework version and the state of the plugin registry (`plugins.registry_url`, read through `kiosk.RegistryURL`). With the registry configured, cabana (never the SPA) looks up each versioned plugin's releases through `kiosk.Client` and marks an update when `kiosk.NewestCompatible` finds a release newer than the installed version by `kiosk.Newer` (a `git describe` string or a pseudo-version compares by the tag it builds on), with the `summer plugin:update <id>` command; GET `/plugins/marketplace` searches the registry and adds installed and compatibility markers (compatible when `summer plugin:install` can install some release with the built framework, looking up the releases of a plugin whose newest one needs a newer framework; always compatible when the built framework version is not a release) and the `summer plugin:install <id>` command. Registry calls have a 4 second timeout, at most four run at once, and answers are cached in process (searches 5 minutes, at most 256 distinct searches with expired and then the oldest dropped first, releases 15 minutes, failures 1 minute); a failing registry gives `status` unavailable and never a non-200 answer.
- Administrators screen: when some plugin registers admin controllers, `cabana.Activate` also compiles the admin's own screens, an internal plugin with ID `summercms.cabana`. Settings → System → Administrators (controller `cabana.AdministratorsControllerID`, permission `cabana.PermissionManageUsers`) lists backend administrators with their role and last sign-in, opens a preview of each, where record actions (the admin's own and contributed ones) are offered, and creates and edits administrators: login, email, names, a password (hashed at `admin.password.bcrypt_cost`, at least 4 characters and at most 72 bytes), the role and per-administrator permission overrides. The server enforces the privilege rules in the save transaction: only a superuser sees superusers or sends the superuser switch, an administrator can neither grant permissions or roles above their own grants nor change an administrator who holds more than they do, their own role, superuser flag, permissions and password are not changed here, and the last active superuser cannot be demoted. A password set for another administrator ends their sessions (personal API tokens stay valid, as with `admin:reset-password`). Administrators are activated, deactivated and restored by record and bulk actions and deleted softly through `deleted_at` (never hard-deleted); the list shows deleted and deactivated rows with their row states and filters deleted ones, and the preview shows a status hint. Deactivating or deleting an administrator ends their sessions and stops their personal API tokens; the last active superuser cannot be demoted, deactivated or deleted on any of these paths (a transaction-scoped advisory lock serializes the check). The permission codes `cabana.PermissionManageUsers` and `cabana.PermissionManageRoles` are registered with no role grant, so after an upgrade only superusers see the screens until an administrator grants them. An application without admin controllers still activates no admin.
- Danger record actions: `pact.AdminRecordAction.Danger` is served as `danger` on the record response's `cabana.RecordAction` (absent when false, so existing payloads are unchanged); the admin's confirm dialog then uses its danger variant.
- Prompted record actions: a `pact.AdminRecordAction` with a `Prompt` (`pact.AdminActionPrompt`) asks the administrator for one text value in its confirm dialog. The record response serves it as `prompt` on the action's `cabana.RecordAction` (absent for every other action, so existing payloads are unchanged), and the action route accepts the body key `payload`, a JSON string, only for such an action: trimmed, not empty and at most `max_length` runes (422 on `payload` with `backend::lang.form.action_prompt_required` or `action_prompt_too_long`, `backend::lang.form.action_prompt_invalid` for another JSON type), then handed to `Run` as `pact.AdminRecordActionInput.Value`. Every other record action keeps refusing `record_id`, `values` and `payload`. A prompt without a label fails boot, owned or contributed.

View File

@@ -92,9 +92,11 @@ type InstalledPlugin struct {
// MarketplacePlugin is one plugin of the registry's search on the Plugins
// screen: the registry's listing (name, description, category, featured
// flag, https icon URL, newest release and its framework minimum), whether
// the application already has it (Installed), whether the built framework
// satisfies the newest release's minimum (Compatible) and the summer
// command that installs it.
// the application already has it (Installed), whether summer
// plugin:install can install a release of it with the built framework
// (Compatible: the newest release, or an older one when the newest needs
// a newer framework; true when the built framework version is not a
// release and cannot be judged) and the summer command that installs it.
type MarketplacePlugin struct {
ID string `json:"id"`
Name string `json:"name"`
@@ -375,6 +377,10 @@ func applyUpdate(plugin *InstalledPlugin, versions []kiosk.RegistryVersion, fram
if newest.Version == "" || (compatible && kiosk.CompareVersions(newest.Version, latest.Version) <= 0) {
return
}
if !kiosk.ValidVersion(framework) {
// An unknown or devel framework cannot be judged (WR-10).
return
}
if kiosk.Comparable(plugin.Version) && !kiosk.Newer(newest.Version, plugin.Version) {
return
}
@@ -461,6 +467,7 @@ func (s *service) pluginsMarketplace(w http.ResponseWriter, r *http.Request) {
installed[p.ID] = true
}
framework := frameworkRelease(overview.Framework)
known := kiosk.ValidVersion(framework)
for _, p := range list.Data {
if !marketplaceIDRe.MatchString(p.ID) {
// The ID ends up in a shell command; never offer an odd one.
@@ -470,11 +477,17 @@ func (s *service) pluginsMarketplace(w http.ResponseWriter, r *http.Request) {
ID: p.ID, Name: p.Name, Description: p.Description, Category: p.Category,
Featured: p.Featured, IconURL: httpsURL(p.IconURL),
LatestVersion: p.LatestVersion, LatestFramework: p.LatestFramework,
Installed: installed[p.ID],
Compatible: kiosk.Satisfies(framework, p.LatestFramework),
Installed: installed[p.ID],
// A framework version that is not a release (an unknown or
// devel build) cannot be judged: no marker rather than a
// wrong one on every plugin (WR-10).
Compatible: !known || kiosk.Satisfies(framework, p.LatestFramework),
InstallCommand: "summer plugin:install " + p.ID,
})
}
if known {
catalog.olderCompatible(r.Context(), out.Data, framework)
}
out.Meta.Total = len(out.Data)
if q == "" && category == "" {
out.Meta.Categories = categoriesOf(list.Data)
@@ -484,6 +497,36 @@ func (s *service) pluginsMarketplace(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, out)
}
// olderCompatible marks as compatible the plugins whose newest release
// needs a newer framework but which have an older release framework
// allows, the one summer plugin:install would pick. It looks their
// releases up (cached, at most registryLookups at a time); a failed
// lookup leaves the plugin as it is.
func (c *pluginCatalog) olderCompatible(ctx context.Context, plugins []MarketplacePlugin, framework string) {
var wg sync.WaitGroup
slots := make(chan struct{}, registryLookups)
for i := range plugins {
plugin := &plugins[i]
if plugin.Compatible {
continue
}
wg.Add(1)
slots <- struct{}{}
go func() {
defer wg.Done()
defer func() { <-slots }()
versions, listed, err := c.releases(ctx, plugin.ID)
if err != nil || !listed {
return
}
if _, ok := kiosk.NewestCompatible(versions, framework); ok {
plugin.Compatible = true
}
}()
}
wg.Wait()
}
// categoriesOf returns the distinct valid categories of the plugins the
// marketplace offers (valid IDs only), sorted.
func categoriesOf(plugins []kiosk.RegistryPlugin) []string {

View File

@@ -377,6 +377,51 @@ func TestPluginsEndpoints(t *testing.T) {
}
})
t.Run("marketplace compatibility", func(t *testing.T) {
list := []kiosk.RegistryPlugin{
{ID: "acme.shop", Name: "Shop", LatestVersion: "v1.0.0", LatestFramework: "v0.9.0"},
{ID: "acme.old", Name: "Old", LatestVersion: "v0.3.0", LatestFramework: "v0.9.0"},
{ID: "acme.blog", Name: "Blog", LatestVersion: "v0.2.0", LatestFramework: "v0.6.0"},
}
versions := map[string][]kiosk.RegistryVersion{
"acme.shop": {rel("v1.0.0", "v0.9.0"), rel("v0.9.0", "v0.8.0")},
"acme.old": {rel("v0.3.0", "v0.9.0"), rel("v0.1.0", "v0.5.0")},
}
compat := func(t *testing.T, framework string) (map[string]bool, *memPluginRegistry) {
t.Helper()
reg := &memPluginRegistry{list: list, versions: versions}
installed := testInstalled()
installed.Framework = framework
svc := unitPluginsService(t, installed, reg, clock)
got := map[string]bool{}
for _, p := range decodeMarketplace(t, pluginsCall(svc, (*service).pluginsMarketplace, pluginsViewer, "/plugins/marketplace")).Data {
got[p.ID] = p.Compatible
}
return got, reg
}
// A release framework: an older compatible release counts, as it
// does for plugin:install; only the incompatible are looked up.
got, reg := compat(t, "v0.6.0")
if got["acme.shop"] || !got["acme.old"] || !got["acme.blog"] {
t.Errorf("v0.6.0: %v", got)
}
if reg.count("versions acme.blog") != 0 {
t.Error("a compatible plugin was looked up")
}
// A framework that is not a release cannot be judged: no marker.
for _, fw := range []string{"(devel)", "", "abc1234"} {
got, reg := compat(t, fw)
if !got["acme.shop"] || !got["acme.old"] || !got["acme.blog"] || reg.count("versions") != 0 {
t.Errorf("%q: %v", fw, got)
}
}
p := InstalledPlugin{ID: "acme.blog", Version: "v0.1.0"}
applyUpdate(&p, []kiosk.RegistryVersion{rel("v0.3.0", "v0.7.0")}, frameworkRelease("(devel)"))
if p.LatestFramework != "" || p.UpdateAvailable {
t.Errorf("devel framework marks the installed plugin: %+v", p)
}
})
t.Run("marketplace", func(t *testing.T) {
reg := &memPluginRegistry{list: []kiosk.RegistryPlugin{
{ID: "acme.blog", Name: "Blog", Category: "content", LatestVersion: "v0.2.0", LatestFramework: "v0.6.0", IconURL: "https://cdn.example.test/blog.png"},