diff --git a/.planning/phases/34-plugin-marketplace/34-REVIEW.md b/.planning/phases/34-plugin-marketplace/34-REVIEW.md new file mode 100644 index 00000000..40a22c78 --- /dev/null +++ b/.planning/phases/34-plugin-marketplace/34-REVIEW.md @@ -0,0 +1,328 @@ +--- +phase: 34-plugin-marketplace +reviewed: 2026-10-29T12:00:00Z +depth: standard +files_reviewed: 81 +files_reviewed_list: + - admin/src/api/types.ts + - admin/src/app/pageTitle.ts + - admin/src/app/router.ts + - admin/src/components/shell/AppShell.vue + - admin/src/components/shell/Breadcrumbs.vue + - admin/src/components/shell/PluginRail.vue + - admin/src/state/useSettings.ts + - admin/src/views/PluginsView.vue + - cmd/summer/main.go + - examples/hello/main.go + - examples/hello/plugins.gen.go + - internal/build/build.go + - internal/build/install.go + - internal/build/manifest.go + - internal/build/new.go + - internal/build/plugindirs.go + - internal/build/scaffold.go + - internal/build/source.go + - internal/build/update.go + - modules/cabana/admin_openapi.go + - modules/cabana/backend_screens.go + - modules/cabana/http.go + - modules/cabana/navigation.go + - modules/cabana/plugins.go + - modules/kiosk/check.go + - modules/kiosk/client.go + - modules/kiosk/derive.go + - modules/kiosk/identity.go + - modules/kiosk/kiosk.go + - modules/kiosk/manifest.go + - modules/kiosk/registry.go + - modules/kiosk/version.go + - modules/party/info.go + - modules/phrasebook/backend/lang/en/lang.yaml + - modules/phrasebook/backend/lang/pl/lang.yaml + - scripts/check-phase34.sh + - sm-summercmsio-app/plugins/golem15/summercms/admin.go + - sm-summercmsio-app/plugins/golem15/summercms/admin_navigation.go + - sm-summercmsio-app/plugins/golem15/summercms/admin_permissions.go + - sm-summercmsio-app/plugins/golem15/summercms/console/registry.go + - sm-summercmsio-app/plugins/golem15/summercms/controllers/api/registry.go + - sm-summercmsio-app/plugins/golem15/summercms/controllers/plugins.go + - sm-summercmsio-app/plugins/golem15/summercms/controllers/plugins/config_form.yaml + - sm-summercmsio-app/plugins/golem15/summercms/controllers/plugins/config_list.yaml + - sm-summercmsio-app/plugins/golem15/summercms/controllers/repositories.go + - sm-summercmsio-app/plugins/golem15/summercms/controllers/repositories/config_form.yaml + - sm-summercmsio-app/plugins/golem15/summercms/controllers/repositories/config_list.yaml + - sm-summercmsio-app/plugins/golem15/summercms/go.mod + - sm-summercmsio-app/plugins/golem15/summercms/jobs.go + - sm-summercmsio-app/plugins/golem15/summercms/lang.go + - sm-summercmsio-app/plugins/golem15/summercms/models/plugin.go + - sm-summercmsio-app/plugins/golem15/summercms/models/plugin/columns.yaml + - sm-summercmsio-app/plugins/golem15/summercms/models/plugin/fields.yaml + - sm-summercmsio-app/plugins/golem15/summercms/models/registry.go + - sm-summercmsio-app/plugins/golem15/summercms/models/repository.go + - sm-summercmsio-app/plugins/golem15/summercms/models/repository/columns.yaml + - sm-summercmsio-app/plugins/golem15/summercms/models/repository/fields.yaml + - sm-summercmsio-app/plugins/golem15/summercms/models/version.go + - sm-summercmsio-app/plugins/golem15/summercms/plugin.go + - sm-summercmsio-app/plugins/golem15/summercms/registry/git.go + - sm-summercmsio-app/plugins/golem15/summercms/registry/ingest.go + - sm-summercmsio-app/plugins/golem15/summercms/registry/query.go + - sm-summercmsio-app/plugins/golem15/summercms/routes_registry.go + - sm-summercmsio-app/plugins/golem15/summercms/schedule.go + - sm-summercmsio-app/plugins/golem15/summercms/token_scopes.go + - sm-summercmsio-app/plugins/golem15/summercms/updates/202610300001_create_golem15_summercms_registry.go + - sm-summercmsio-app/plugins/golem15/summercms/updates/registry.go + - sm-summercmsio-app/vue-summercmsio-app/app/components/SiteFooter.vue + - sm-summercmsio-app/vue-summercmsio-app/app/components/SiteHeader.vue + - sm-summercmsio-app/vue-summercmsio-app/changelog/app/composables/useRegistry.ts + - sm-summercmsio-app/vue-summercmsio-app/changelog/app/error.vue + - sm-summercmsio-app/vue-summercmsio-app/changelog/app/layouts/default.vue + - sm-summercmsio-app/vue-summercmsio-app/changelog/app/pages/marketplace/[vendor]/[name].vue + - sm-summercmsio-app/vue-summercmsio-app/changelog/app/pages/marketplace/index.vue + - sm-summercmsio-app/vue-summercmsio-app/changelog/app/utils/marketplace.ts + - sm-summercmsio-app/vue-summercmsio-app/changelog/nuxt.config.ts + - sm-summercmsio-app/vue-summercmsio-app/nuxt.config.ts + - sm-summercmsio-app/scripts/changelog-post.py + - sm-summercmsio-app/config/http.yaml + - sm-summercmsio-app/config/queue.yaml + - sm-summercmsio-app/deploy/nginx/summercms.io.conf +findings: + critical: 2 + warning: 11 + info: 9 + total: 22 +status: issues_found +--- + +# Phase 34: Code Review Report + +**Reviewed:** 2026-10-29T12:00:00Z +**Depth:** standard +**Files Reviewed:** 81 +**Status:** issues_found + +## Summary + +Scope: the kiosk module (plugin.yaml, static identity reader, registry client and version rules), `summer plugin:manifest/install/update`, `summer build` metadata generation (`party.Describe`, `PluginInfo`), the cabana Plugins screen and SPA, the summercms.io registry (ingester, public API, ingest-now, admin), the Nuxt `/marketplace` pages and the deploy/config changes. Diff bases were framework `824e3d5..HEAD`, site plugin `ca59415..HEAD` and vue app `b3bfc5d..HEAD`. + +The hardening is mostly sound. Every git and go exec uses an argv with no shell and puts `--` before URLs and paths. Registry strings go through strict version, module path and https checks before they reach exec, and plugin IDs are regex-checked before they become paths or shell commands. The ingester runs git with a throwaway HOME, no system or global config and an https-only `GIT_ALLOW_PROTOCOL`, and caps every read. The README HTML is sanitized twice before `v-html`. Admin requests to the registry are bounded, cached and permission-gated. The registry uses parameterized queries with escaped LIKE wildcards. No SQL injection, command injection, path traversal or XSS was found. + +The main problems are in the version logic of the install and update CLI: + +- `plugin:update` can downgrade a plugin installed at a pseudo-version (CR-01). +- `plugin:install` can silently move an already-installed plugin that shares a Go module with the plugin being added (CR-02). + +Other findings: + +- **Compatibility:** generated code breaks apps pinned to an older framework (WR-01). +- **Robustness:** partial installs are not rolled back (WR-02); an unbounded admin cache (WR-06); the hourly ingest can be starved by the job timeout (WR-09). +- **Credential handling:** tokens in URLs end up in `.gitmodules` (WR-03); the journal token is sent to the registry endpoint (WR-04). +- **Least privilege:** the `registry` token scope is broader than ingest needs (WR-05). +- **Registry moderation:** disabling a repository does not withdraw its plugins (WR-07); a stale `last_error` sticks after a transient failure (WR-08). + +No structural (fallow) findings were provided for this run. + +## Critical Issues + +### CR-01: `plugin:update` downgrades module installs pinned at a pseudo-version + +**File:** `internal/build/update.go:118-123`, `internal/build/update.go:286-301`, `internal/build/update.go:311-342` +**Issue:** `installedSource` throws away any go.mod require that is not a strict `vX.Y.Z` (`if !kiosk.ValidVersion(src.Version) { src.Version = "" }`). `pick` then treats `""` as `v0.0.0`, so every registry release counts as "newer". Take an app that requires a plugin at a commit after its last tag, for example `M v0.4.1-0.20261001120000-abcdef123456` (commonly from `go get M@main`). `plugin:update` picks `v0.4.0` and runs `go get M@v0.4.0`, which is a **downgrade** that drops the commits the app depends on. The plan line prints `default branch head -> v0.4.0 (module)`, which does not read as a downgrade, and with `--yes` (the documented non-interactive mode) it runs unseen. The cabana Plugins screen gives the same install no update badge (`BaseVersion` of a pseudo-version is `""`), so the comment "the rule is summer plugin:update's" (`modules/cabana/plugins.go:325-329`) is also false. +**Fix:** Keep the pseudo-version and compare against the release it builds on. Never offer a release at or below that base: +```go +// installedSource, module branch +if src.Dir == "" { + src.Kind = ModeModule + src.Pseudo = src.Version // keep for reporting + if !kiosk.ValidVersion(src.Version) { + src.Version = pseudoFloor(src.Version) // vX.Y.(Z-1) for vX.Y.Z-0.-, "" when unknown + } + return src, nil +} + +// planModule: refuse to guess when the installed version is unknown +if src.Version == "" && src.Pseudo != "" { + u.note(p.ID, "installed at "+src.Pseudo+", a commit, not a release; pin a release with plugin:update after `go get "+src.Module+"@`") + return +} +``` +Add a regression test in `install_smoke_test.go#update` with a pseudo-version require. + +### CR-02: `plugin:install` silently moves already-installed plugins that share a Go module + +**File:** `internal/build/install.go:395-406`, `internal/build/install.go:571-612`, `internal/build/install.go:310-341` +**Issue:** `moduleVersion` holds only modules chosen *in this plan*. If the plugin being installed (or one of its requirements) lives in a module the app already requires for another plugin, `choose` still picks the newest compatible release of that module. One example is a second plugin package inside `sm-pgstripe-plugin`, where the app has the main plugin at v0.2.0. `ApplyInstall` then runs `go get M@v0.3.0`, which upgrades the plugin the app already has. The plan prints only the new plugin (`acme.sub@v0.3.0 (module)`). This contradicts the guarantee the code states twice ("plugin:install does not move plugins the application already has", `install.go:337`; "plugin:install never moves the framework"). It can also break the existing plugin's own requirements, which are never re-checked. In `--source` mode it gets worse: the same repository is cloned and added a second time as a submodule under another directory, so go.work ends up with one module path in two `use` directories and the workspace stops loading. +**Fix:** Seed `moduleVersion` from the app's go.mod and go.work before resolving, and pin plugins of an already-required module to the installed version (or fail with a hint to run `plugin:update`): +```go +// PlanInstall, after EffectiveFramework +for _, p := range m.Plugins { + if src, err := installedSource(ctx, appDir, p.Module); err == nil && src.Module != "" { + if src.Kind == ModeSubmodule { + in.installedDirs[src.Module] = src.Dir // reuse, never clone again + } + in.moduleVersion[src.Module] = src.Version // "" means: refuse, cannot pin + } +} +// choose(): when want, ok := in.moduleVersion[v.Module]; ok && want == "" -> error naming plugin:update +``` +For a submodule module that is already installed, register the new package from the existing checkout instead of calling `applySubmodule`. + +## Warnings + +### WR-01: Generated `main.go`/`plugins.gen.go` require framework >= v0.6.0 with no guard (breaks apps on older framework) + +**File:** `internal/build/build.go:131`, `internal/build/build.go:159-188` +**Issue:** `summer build` now always emits `party.Describe(app, PluginInfo, FrameworkVersion)` and `[]party.Info{...}`. An application whose go.mod requires the framework at v0.5.x (no local replace) and that is built with a `summer` installed from this tree fails to compile (`undefined: party.Describe`, `undefined: party.Info`). Before, the same build succeeded. `resolveBuildMeta` already computes the effective framework but never uses it to gate the new generated code. That is a breaking change for consumer apps that bump the CLI before the framework, against the project's no-breaking-changes rule. +**Fix:** Generate the Describe call and the `PluginInfo` declarations only when the resolved framework package provides them. Check for the identifier statically, for example by running `go list -f '{{.Dir}}' git.golem15.com/golem15/summercms/modules/party` and scanning `info.go`. Do not compare version tags: `git describe --abbrev=0` reports v0.5.3 for an untagged v0.6.0 checkout. Otherwise fail early with a hint to bump the framework. + +### WR-02: Install and update are not atomic; a failure leaves the app half-modified + +**File:** `internal/build/source.go:422-451`, `internal/build/install.go:588-615`, `internal/build/update.go:488-509` +**Issue:** +- In `applySubmodule`, once `os.Rename(dir, target)` succeeds the checkout is no longer in `plan.temps`. A failure in `git submodule add`, `absorbgitdirs`, `editAppModule` or `ensureGoWork` leaves `plugins//` behind, and go.mod may already be edited. +- In `ApplyInstall`, submodules are added before `go get`. A failing `go get` leaves the added submodules without summer.yaml entries. +- The next `plugin:install` then refuses ("already exists; remove it or register it with plugin:add"). +- `updater.apply` checks submodules out and edits go.mod one by one. A failure mid-loop leaves some plugins moved and others not, and `refreshWorkOverrides` is skipped. + +**Fix:** Snapshot go.mod, go.sum, go.work and summer.yaml before applying, and on error restore them. Also remove moved checkouts and run `git submodule deinit -f`/`git rm --cached` for submodules added in this run, or at least print the exact cleanup commands. In `applySubmodule`, register the target directory for cleanup right after the rename. + +### WR-03: Credentials in a user-supplied https git URL end up in `.gitmodules` and output + +**File:** `internal/build/source.go:117-119`, `internal/build/source.go:233`, `internal/build/source.go:430` +**Issue:** `validateGitURL` only rejects a URL with a *password*. GitHub, GitLab and Gitea all accept a token as the user part (`https://ghp_xxx@github.com/acme/plugin.git`). Such a URL passes, is printed (`Cloning %s`) and is written by `git submodule add` into `.gitmodules`, which is committed and pushed with the application. +**Fix:** For user-supplied URLs, refuse `u.User != nil` on `https://` (keep it for `ssh://git@...` and scp-like forms, where the user is not a secret): +```go +if scheme == "https" && u.User != nil { + return fmt.Errorf("git URL must not carry user info; use a git credential helper") +} +``` + +### WR-04: changelog-post sends the journal token to the registry endpoint + +**File:** `sm-summercmsio-app/scripts/changelog-post.py` (`registry_credential`, `request_ingest`, `cmd_publish`) +**Issue:** Without `~/.config/summercms/registry-token`, `request_ingest(token)` posts the **journal** write token to `SUMMERCMS_REGISTRY_API` (overridable by env), even though the docstring says this "the registry answers with 403". The credential goes to an endpoint that is known to reject it, and if `SUMMERCMS_REGISTRY_API` points anywhere else (staging or a typo), the journal token leaks to that host. This goes against the project rule to never copy the journal token anywhere. +**Fix:** Drop the fallback: when no registry token exists, skip the ingest request with a warning ("no registry token; the hourly ingest will pick it up"): +```python +cred = read_secret_file(REGISTRY_TOKEN_FILE) +if not cred: + print("changelog-post: warning: no registry token; the hourly ingest will pick it up", file=sys.stderr) + return +``` + +### WR-05: The `registry` token scope covers all of `golem15.summercms.*`, not just ingest + +**File:** `sm-summercmsio-app/plugins/golem15/summercms/token_scopes.go:12-19`, `sm-summercmsio-app/plugins/golem15/summercms/controllers/api/registry.go:30` +**Issue:** The release script's token scope allows `GET/HEAD/POST` on every `golem15.summercms.*` permission within the owner's rights (`methodFilterScopes` in cabana matches by pattern). The owner is normally a superuser, so the token can POST to the admin API controllers guarded by `golem15.summercms.registry`, for example to create curated repositories. That is a supply-chain lever over the public marketplace, and more than the one ingest call the token exists for. nginx refusing `/backend` limits this to tunnel access, but it is still broader than least privilege. +**Fix:** Scope it to the ingest permission only, and cover the same code in `Ingest`: +```go +Permissions: []string{"golem15.summercms.registry_ingest"}, +// controllers/api/registry.go +const ingestCover = "golem15.summercms.registry_ingest" +``` + +### WR-06: The admin Plugins screen's search cache grows without bound + +**File:** `modules/cabana/plugins.go:228-253` +**Issue:** `pluginCatalog.lists` is keyed by every distinct `q` (up to 100 runes) and `category`. Expired entries are never deleted, only overwritten on the same key. Each entry keeps a full `kiosk.ListResponse` (up to 4 MiB), and failures are cached too. Any principal with `backend.view_plugins` can grow process memory without limit by searching distinct strings, and so can a debounced search box used over a long session. +**Fix:** Bound the map. Drop expired entries on insert and cap the size (for example 256 keys, oldest out), or cache only the unfiltered list and filter locally. + +### WR-07: Disabling a repository does not withdraw its plugins from the public registry + +**File:** `sm-summercmsio-app/plugins/golem15/summercms/registry/query.go:45`, `:85`, `:133-134`; `sm-summercmsio-app/plugins/golem15/summercms/registry/ingest.go:116-118` +**Issue:** `List`, `Versions` and `Detail` filter only on `plugins.published`. When an administrator disables a repository (for example because it was compromised), `Repository()` returns early, so `published` never changes. The plugins stay listed and installable through `summer plugin:install`. The admin has no other unpublish control (`published` is preview-only and plugin delete is forbidden), so the only kill switch is deleting the repository, which also loses category, featured flag, icon and screenshots. The lang string documents this ("their plugins stay as they are"), but it leaves the marketplace with no way to pull a bad plugin quickly. +**Fix:** Join on the repository in the public queries: +```go +query := db.WithContext(ctx).Preload("Repository"). + Joins("JOIN golem15_summercms_registry_repositories r ON r.id = golem15_summercms_registry_plugins.repository_id AND r.enabled"). + Where("golem15_summercms_registry_plugins.published = ?", true) +``` +Apply the same join in `Versions` and `Detail`. Alternatively, unpublish the repository's plugins in `FormBeforeUpdate` when `enabled` turns false. + +### WR-08: A transient ingest failure leaves a stale `last_error` forever + +**File:** `sm-summercmsio-app/plugins/golem15/summercms/registry/ingest.go:136-141` +**Issue:** `recordError` writes `last_error` but leaves `tags_digest` alone. On the next successful run the digest still matches, so the "unchanged" path updates only `last_ingested_at`/`updated_at`. The old error (for example a network timeout) stays in the admin's Repositories list until the tags change, and administrators see a failure that no longer exists. +**Fix:** Clear the error on the unchanged path: +```go +Updates(map[string]any{"last_ingested_at": now, "updated_at": now, "last_error": ""}) +``` +If notes from the last full read should survive, store them in a separate column. + +### WR-09: The hourly `IngestAll` runs inside one 300 s job and starves later repositories + +**File:** `sm-summercmsio-app/plugins/golem15/summercms/registry/ingest.go:165-182`, `sm-summercmsio-app/plugins/golem15/summercms/schedule.go:20-24`, `sm-summercmsio-app/config/queue.yaml` (`job_timeout: 300`) +**Issue:** The scheduled command runs as a conga job with a 300 s deadline. `IngestAll` processes repositories sequentially in id order. Each one can take up to 30 s (ls-remote), plus 180 s (clone, retried once on failure: up to 360 s), plus 30 s per tag operation. One slow or hanging repository uses up the deadline, so every later repository gets `ctx.Err()` and is reported failed. This repeats every hour in the same order, so the tail of the list is never ingested. `queues.default: 1` also blocks ingest-now jobs behind it. +**Fix:** Enqueue one `IngestArgs` job per enabled repository from the scheduled command (each gets its own deadline and retry), or give `IngestAll` per-repository budgets and rotate the start index. + +### WR-10: Plugins screen and `plugin:install` disagree on compatibility for unknown or dev framework versions + +**File:** `modules/cabana/plugins.go:435-447`, `modules/cabana/plugins.go:487-495` +**Issue:** `frameworkRelease` returns the raw string when it is not a release, for example `""` from an app built by `summer new`, `(devel)` from `bonfire.BuildVersion`, or a pseudo-version. `kiosk.Satisfies` is then false for every plugin with a framework minimum. The marketplace marks *every* plugin "needs framework vX" and `Latest` stays empty, while `plugin:install` (which uses `EffectiveFramework`) would install them fine. `Compatible` also checks only the newest release, so a plugin with an older compatible release is still shown as incompatible even though `plugin:install` would pick that release. +**Fix:** Return a tri-state (unknown, compatible, incompatible) and omit the marker when the framework is unknown. Compute `Compatible` from `NewestCompatible` over the versions (cached), or rename the field `latestCompatible`. + +### WR-11: `plugin:add` now fails on summer.yaml files with an inline `plugins:` list (regression) + +**File:** `internal/build/scaffold.go:207`, `internal/build/manifest.go:255-260` +**Issue:** `AddPlugin` switched from `SaveManifest` (full rewrite, any YAML shape) to `appendManifestPlugins`, which refuses a flow-style list (`plugins: [{id: a, module: b}]`) with "write it as a block list". A summer.yaml that `plugin:add` used to handle now errors, which breaks an existing CLI command's behavior. +**Fix:** When `insertManifestPlugins` reports an inline list, fall back to the previous `SaveManifest(manifestPath, m)` with a notice that comments were not preserved. + +## Info + +### IN-01: Ingest-now accepts a cookie/JWT admin session without a CSRF guard + +**File:** `sm-summercmsio-app/plugins/golem15/summercms/controllers/api/registry.go:126-149` +**Issue:** The endpoint decodes any body as JSON regardless of `Content-Type`, so a cross-site `text/plain` form can produce a valid body if a session credential is ambient. The impact is limited to queuing an ingest of an already-curated repository (and nginx refuses `/backend` sessions), but requiring `Content-Type: application/json` costs nothing. +**Fix:** Reject requests whose `Content-Type` is not `application/json`. + +### IN-02: The registry client accepts plain `http://` registries + +**File:** `modules/kiosk/client.go:74-76` +**Issue:** The registry answer decides which module path `go get` fetches. Over `http://`, an on-path attacker can substitute any module and code runs at build time. The default is https, but nothing warns when an operator sets `http://`. +**Fix:** Allow `http://` only for loopback hosts, or print a warning in `registryFor`/`newPluginCatalog`. + +### IN-03: The nested `kiosk.Validate` after `kiosk.Parse` is redundant + +**File:** `sm-summercmsio-app/plugins/golem15/summercms/registry/ingest.go:275-278` +**Issue:** `Parse` already calls `Validate`. The second call adds nothing. +**Fix:** Remove it. + +### IN-04: Plugin ID ownership is first-come and never released + +**File:** `sm-summercmsio-app/plugins/golem15/summercms/registry/ingest.go:439-451` +**Issue:** A repository that once published an ID keeps it after it stops publishing it (the row is only unpublished). Any other curated repository that publishes the ID later is refused until an administrator deletes the original repository. A curated repository can also squat an unrelated vendor's ID, because IDs are not tied to the module or vendor. +**Fix:** Release the ID when the owning row is unpublished, and consider requiring the ID vendor to match an allow-list per repository. + +### IN-05: The Nitro SWR route cache on `/marketplace` is keyed by query string + +**File:** `sm-summercmsio-app/vue-summercmsio-app/changelog/nuxt.config.ts` (`routeRules['/marketplace']`) +**Issue:** Every distinct `?q=` becomes a cache entry for 300 s. With in-memory cache storage, random queries grow the Node process's memory, limited only by the per-IP API throttle on cache misses. +**Fix:** Cache only `/marketplace` without a query (and the detail pages), or configure a bounded cache storage. + +### IN-06: The detail page trusts the registry for the repository link's scheme + +**File:** `sm-summercmsio-app/vue-summercmsio-app/changelog/app/pages/marketplace/[vendor]/[name].vue:135` +**Issue:** `:href="plugin.repository.replace(/\.git$/, '')"` does no scheme check. The server canonicalizes URLs to https today, but `Repository.BeforeSave` keeps non-https URLs unchanged (as the tests rely on), so a `javascript:` value written directly to the database would become a clickable link. +**Fix:** Render the link only when `plugin.repository.startsWith('https://')`. + +### IN-07: The marketplace icon is loaded from third-party URLs in the admin + +**File:** `admin/src/views/PluginsView.vue:371-378` +**Issue:** Each Marketplace tab view makes the administrator's browser fetch registry-provided icon URLs (any https host), and every Plugins screen view sends the installed plugin IDs to summercms.io by default. `referrerpolicy="no-referrer"` helps, but the default registry is opt-out. +**Fix:** Document the outbound calls in the admin-spa docs, or proxy icons through cabana. + +### IN-08: `GET /plugins` and `GET /plugins/marketplace` use different envelopes + +**File:** `modules/cabana/plugins.go:395`, `modules/cabana/plugins.go:421-456` +**Issue:** The installed list is wrapped (`WriteData` → `{data: ...}`), while the marketplace answers `MarketplaceResponse` raw via `writeJSON`. It is documented in OpenAPI, but inconsistent for SPA clients. +**Fix:** Pick one shape for new admin endpoints. + +### IN-09: The committed `examples/hello/plugins.gen.go` bakes `FrameworkVersion = "v0.5.3"` on a v0.6.0 tree + +**File:** `examples/hello/plugins.gen.go:29` +**Issue:** `EffectiveFramework` uses `git describe --abbrev=0`, so the generated constant names the previous tag until v0.6.0 is tagged and the file is regenerated. It shows up as drift in the committed example. Related: the site plugin's `go.mod` requires `summercms v0.6.0`, which does not exist as a tag yet; a standalone `go mod download` fails until it is tagged. +**Fix:** Regenerate the example after tagging v0.6.0 (add it to the release ledger), and tag the framework before pushing the site plugin's go.mod bump. + +--- + +_Reviewed: 2026-10-29T12:00:00Z_ +_Reviewer: Claude (msd-code-reviewer)_ +_Depth: standard_