34 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, estimate, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | estimate | must_haves | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| quick-261007-p4y | 01 | execute | 1 |
|
true |
|
|
|
-
The admin toggle (SwitchField) looks too tall for its width. Root cause: admin/src/styles/main.css sets the html font size to 14px, so the Tailwind v4 rem utilities w-11 (38.5px) and size-5 (17.5px) shrink against the fixed h-[26px] track and translate-x-[18px] travel. The fix (w-[44px] and size-[20px]) is ALREADY APPLIED, uncommitted, in admin/src/components/form/fields/SwitchField.vue. It needs its test check, a dist rebuild and its own commit.
-
WinterCMS lets registerSettings point an entry at a backend controller ('url' => Backend::url('golem15/translate/locales')), so the translate plugin's Locales screen lives on the Settings page, not in the main navigation. SummerCMS core only supports singleton-model settings, so the Go translate plugin registers a main navigation item instead. This task adds an additive
Controllerfield to pact.SettingsItem with registry validation, metadata output, permission filtering and singleton-endpoint 404s, plus the SPA card link and Settings-aware shell. The translate plugin then switches from Navigation() to Settings().
Purpose: restore the WinterCMS admin shape (Locales under Settings) without breaking the pact contract that many plugins and apps depend on, and fix a visibly wrong control. Output: three summercms.go commits (switch fix with dist; Go contract with OpenAPI, READMEs and docs; SPA with tests and dist) and one sm-translate-plugin commit.
<execution_context>
@/.claude/gsd-core/workflows/execute-plan.md
@/.claude/gsd-core/templates/summary.md
</execution_context>
Facts established during planning (do not re-derive):
- The SPA builds into modules/boardwalk/dist (admin/vite.config.ts outDir, emptyOutDir). Rebuild with
npm --prefix admin run build(vue-tsc then vite build).scripts/check-admin-dist.shfails when dist differs from a fresh build. Stage dist withgit add -A modules/boardwalk/distso hashed asset renames and deletions are captured (see commitsf15b38eanda586357). - No admin test asserts the switch's w-11 or size-5 classes. tests/form/fields.test.ts and tests/form/registry.test.ts mount SwitchField behaviourally.
scripts/check-admin-openapi.sh(no argument) regenerates admin/openapi/admin.json and admin/src/api/schema.d.ts from the cabana swag annotations.--checkfails on drift. Every SettingsEntry property is currentlyrequiredin admin.json, so a non-omitemptyjson:"controller"becomes a required string. That matches NavigationEntry.Controller, which is also always present.- admin/tests/fixtures/typed.ts types settings.json as the generated SettingsEntry. Once
controlleris required, every fixture entry needs"controller": ""ornpm --prefix admin run typecheckfails. Build link entries inline in tests rather than adding them to the shared fixture (existing tests count its entries). - The registry builds
reg.byID(compiled controllers) before compileContributions runs, so link validation can checkreg.byIDthe same way validateNavigation does (modules/cabana/registry.go ~line 322). - protectSetting (modules/cabana/http.go ~line 501) resolves the code only through Registry.Setting, so Registry.Setting returning false for a link code turns all three singleton endpoints into 404s. http.go needs no edit.
- Reusable Go fixtures: modules/cabana/metadata_settings_test.go has contributionPlugin, metadataController (requires acme.demo.access), metadataRegistry() (byID has acme.demo.widgets) and metadataPlugin(). HTTP-level settings calls exist in modules/cabana/openapi_conformance_test.go (conformEnv, "GET /settings") and modules/cabana/security_coverage_test.go. Reuse whichever harness mounts the admin API with a backend principal.
- Settings category convention: every Go settings item uses its own plugin phrase key as Category (feedback and fonoteka use lang.plugin.tab, journal uses lang.journal.menu_label). Core has no "System" category key. The PHP reference examples/golem15-wintercms-starter/plugins/golem15/translate/Plugin.php registerSettings uses category 'golem15.translate::lang.plugin.name', label 'golem15.translate::lang.locale.title', description 'golem15.translate::lang.plugin.description', order 550 and permission golem15.translate.manage_locales, with no keywords. All three keys already exist in sm-translate-plugin/lang/en and lang/pl. Per the API-parity rule, follow the PHP reference and add no core category constant.
- sm-translate-plugin is a separate git repo whose go.mod has
replace git.golem15.com/golem15/summercms => ../summercms.go(and a go.work with the same replace), so it builds against the local framework. Do not change its go.mod require version. Its admin harness (admin_harness_test.go: newAdminEnv, e.admin(t, permissions...), e.expect(...), adminAPI("/settings")) boots cabana with the plugin and Postgres via testcontainers. - Consumers of the translate plugin (sm-grzybyfunkcjonalne-app, sm-summercmsio-app) pin it as a submodule. Bumping them is OUT of scope.
Commit rules: one logical change per commit; planning docs never go in a code commit; NO co-author trailers (the user's global CLAUDE.md overrides any attribution reminder); go vet ./... and go test ./... green at every commit. Do not commit anything under .planning (the orchestrator does).
- Run
git status --shortin summercms.go. The only modified file must be admin/src/components/form/fields/SwitchField.vue. If anything else is dirty, stop and report it rather than building it into dist. - Confirm the track uses w-[44px] with h-[26px], and the knob uses size-[20px] with top-[3px], left-[3px] and translate-x-[18px]. The geometry is 3 + 20 + 18 + 3 = 44, so it is symmetric. Leave the colour, aria and transition classes untouched.
- Grep admin/tests for any assertion on w-11 or size-5 tied to the switch. Planning found none. If one exists, update it to the pixel classes.
- Run the switch tests, then
npm --prefix admin run build, thenscripts/check-admin-dist.sh. - Stage SwitchField.vue and
git add -A modules/boardwalk/dist. Commit asfix(admin): size the switch toggle in pixels at the 14px rootwith a body explaining that the rem utilities shrank the track and knob under html font-size 14px. Add no co-author trailer. cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && grep -q 'w-[44px]' admin/src/components/form/fields/SwitchField.vue && grep -q 'size-[20px]' admin/src/components/form/fields/SwitchField.vue && npm --prefix admin test -- tests/form/fields.test.ts tests/form/registry.test.ts && scripts/check-admin-dist.sh && go test ./modules/boardwalk -count=1 && test -z "$(git status --short)" SwitchField uses pixel width and knob sizes, the switch tests pass, dist matches a fresh build, boardwalk tests pass, and one commit holds SwitchField.vue plus modules/boardwalk/dist with no co-author trailer. The working tree is clean afterwards.
Go contract, commit A:
- modules/pact/capabilities.go: add
Controller stringto SettingsItem, after Permissions and before Form, with no json tag (like the other exported fields). Its doc comment says: when set, the entry is a link to that admin controller ID (vendor.plugin.controller), the equivalent of WinterCMS registerSettings 'url' => Backend::url(...). A link entry declares no Model, Form or NewModel and is not a singleton. Extend the SettingsItem doc comment to say an item is either a singleton form or a controller link. - modules/cabana/settings.go: add
compileSettingLink(pluginID string, item pact.SettingsItem) (*CompiledSetting, error). It errors when item.Form != "" or item.NewModel != nil, with message "cabana: setting %s links controller %s and declares a singleton form; declare one". It errors when item.Model != "", with message "cabana: setting %s links controller %s and names a model". Otherwise it returns &CompiledSetting{PluginID, Item} with Form and Writable nil. Rejecting Model is a planning discretion choice: a link has no model, so the SPA never sees a misleading model string. Update the CompiledSetting doc comment to say Form and Writable are nil for a link entry. - modules/cabana/registry.go compileContributions: in the HasSettings loop, keep the identifier and duplicate checks first. If item.Controller != "", call compileSettingLink, store the result in reg.settings[item.Code] and continue. This skips the AdminFS requirement and compileSetting. Otherwise keep the existing path unchanged. In the post-collection loop over reg.settings, after validatePermissions, a link entry whose controller is not in reg.byID returns "cabana: setting %s references unknown controller %s", mirroring validateNavigation.
- modules/cabana/contracts.go Registry.Setting: return (nil, false) when the stored setting's Item.Controller is non-empty. Update the doc comment: link entries are not singletons and are not returned. This alone makes protectSetting answer 404 for all three singleton endpoints. Leave http.go unchanged.
- modules/cabana/navigation.go: add
Controller stringwith tagjson:"controller"(not omitempty, matching NavigationEntry) to SettingsEntry, after Model, with a doc comment ("the admin controller a link entry opens; empty for a singleton settings form"). In Metadata's settings loop, skip an item unless Allows(principal, item.Permissions) holds and also either item.Controller is empty or r.canOpen(principal, item.Controller) is true. Populate Controller in the appended entry. - modules/cabana/settings_link_test.go (new, package cabana): implement the Go behavior cases, reusing contributionPlugin, metadataRegistry, metadataPlugin and metadataController from metadata_settings_test.go. Add a plugin with nil fsys for the no-AdminFS case. For the 404 and JSON-shape cases, reuse the existing admin HTTP harness (see the Context facts) instead of writing a new one.
- Run
scripts/check-admin-openapi.shwith no argument to regenerate admin/openapi/admin.json and admin/src/api/schema.d.ts. Confirm the only diff is the new controller property, which is required on cabana.SettingsEntry. - admin/tests/fixtures/settings.json: add
"controller": ""to every existing list entry sonpm --prefix admin run typecheckstays green against the regenerated schema.d.ts. - Docs in the same commit, per the CLAUDE.md documentation rule:
- modules/pact/README.md: extend the SettingsItem row and the Features bullet to cover the controller link.
- modules/cabana/README.md: extend the Features bullet on settings, covering link entries, their validation, the 404 on singleton endpoints and the canOpen filtering. Update any Registry.Setting or SettingsEntry rows.
- docs/backend/settings.md: add a section "Linking a settings entry to an admin controller". Explain the WinterCMS 'url' equivalent and the Controller field, that a link entry declares no Model, Form or NewModel, the start-up validation, the permission plus controller-access filtering and how the SPA opens the controller, with a short Go example using neutral names such as acme.blog. A plain go fence without src= is fine unless TestDocsTree requires otherwise.
- docs/setup/coming-from-wintercms.md: extend the registerSettings row to mention 'url' mapping to the Controller field.
- Name no consuming application. If TestDocsTree rejects a field selector such as pact.SettingsItem.Controller, write "the
Controllerfield ofpact.SettingsItem" instead.
- Commit A, Go plus OpenAPI plus fixture plus docs:
feat(cabana): settings entries that link to an admin controller. Add no co-author trailer.
SPA, commit B:
- admin/src/state/useSettings.ts: import controllerPath from ../app/controllerRoutes. Export
settingsPath(entry: SettingsEntry): string. When entry.controller is non-empty it returns controllerPath(entry.controller), falling back to '/settings' when that is null. Otherwise it returns/settings/plus encodeURIComponent(entry.code). ExportsettingsLinkFor(controllerId: string | null): SettingsEntry | null. It returns null for null or an empty string, and otherwise the first entry whose non-empty controller equals the ID. Add both to useSettings(). - admin/src/views/SettingsIndexView.vue: bind the card RouterLink
:toto settingsPath(entry). Keep data-settings. Update the header comment. - admin/src/components/shell/PluginRail.vue: inSettings is also true when active is null and settingsLinkFor(controllerIdFromPath(route.path)) is non-null. A controller that also has a navigation entry keeps its plugin highlight. Update the comment.
- admin/src/components/shell/Breadcrumbs.vue: when activeEntry is null, look up settingsLinkFor(controllerIdFromPath(route.path)). If an entry is found, the crumbs are the Settings label (t('backend::lang.nav.settings'), to /settings), then the entry label (to its controller path), then the record title on record and create routes when non-empty, using the same rule as the plugin branch. Otherwise return [] as today.
- admin/src/app/pageTitle.ts: controllerLabel falls back to settingsLinkFor(controller)?.label after the section and plugin labels.
- Tests: implement the SPA behavior cases in useSettings.test.ts, Settings.test.ts (index), PluginRail.test.ts, Breadcrumbs.test.ts and pageTitle.test.ts. Build link entries inline (for example
setSettings([...settingsFixture.list.data, linkEntry])with controller 'acme.demo.locales' and a vendor or plugin absent from the navigation fixture). Do not change existing assertions. npm --prefix admin run build, thenscripts/check-admin-dist.sh.- Commit B, SPA plus tests plus dist (
git add -A modules/boardwalk/dist):feat(admin): open controller-link settings entries from the settings index. Add no co-author trailer. cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && test -z "$(gofmt -l modules/pact modules/cabana)" && go vet ./modules/pact ./modules/cabana && go test ./modules/cabana -run 'TestSettingsLink' -count=1 -v && go test ./modules/pact ./modules/cabana ./modules/boardwalk -count=1 && scripts/check-admin-openapi.sh --check && grep -q '"controller"' admin/openapi/admin.json && npm --prefix admin run typecheck && npm --prefix admin test && scripts/check-admin-dist.sh && go test ./cmd/summer -run TestDocsTree -count=1 Every Go and SPA behavior case failed before the change and passes after it. Existing metadata, settings-singleton, conformance, security-coverage and SPA tests pass unchanged apart from the fixture's added controller key. admin.json and schema.d.ts match a fresh generation. Dist matches a fresh build. TestDocsTree passes. Two commits exist (feat(cabana) with docs, feat(admin) with dist), neither with a co-author trailer.
- Write the failing tests first.
- In plugin_test.go, add a test asserting the exact Settings() item from the behavior block, compared with reflect.DeepEqual against an expected pact.SettingsItem (NewModel nil on both sides). Assert the interface facts with type assertions on any(&Plugin{}).
- In locales_admin_test.go, add a harness test covering the GET /settings, GET /navigation and 404 cases using newAdminEnv, e.admin and e.expect.
- Create admin_settings.go (package translate). It defines
func (*Plugin) Settings() []pact.SettingsItemreturning the single item, using controllers.PermissionManageLocales. Its doc comment says it replaces registerSettings and that Locales opens from the Settings page, as the PHP plugin's 'url' => Backend::url('golem15/translate/locales') entry did. The values follow the PHP reference exactly: category is the plugin name key, and there are no keywords. Planning chose the reference over a core "System" category because core has none and every other Go settings item uses its own plugin phrase key. - Remove admin_navigation.go with
git rm. In admin.go's interface-assertion block, replace the navigation assertion with_ pact.HasSettings = (*Plugin)(nil). - All three lang keys already exist in lang/en/lang.yaml and lang/pl/lang.yaml (locale.title, plugin.description, plugin.name). Add no lang changes.
- README.md:
- Extend the "Locales admin" Features bullet: Locales opens from the Settings page (category Translate) through a settings entry linking to the controller, not from the main navigation, matching the PHP plugin.
- Add an API reference row
translate.Plugin.Settings: one settings entry, locales, linking to controller golem15.translate.locales and gated by golem15.translate.manage_locales. - Note the framework requirement: the entry uses the Controller field of pact.SettingsItem, so the plugin needs a framework release that has it. Do not edit go.mod.
- Run gofmt, go vet ./... and go test ./... -count=1. The harness tests need Docker for testcontainers.
- Commit in sm-translate-plugin as
feat(settings): open Locales from the Settings page. Add no co-author trailer. - Back in summercms.go, run the full
go vet ./...andgo test ./... -count=1once more to confirm both repos are green. Leave the consuming apps' translate submodules unbumped and note that in the SUMMARY as a follow-up. cd /media/nvme/dev/golem15/summercms.io/summercms/sm-translate-plugin && test ! -e admin_navigation.go && test -z "$(gofmt -l .)" && go vet ./... && go test ./... -count=1 && test -z "$(git status --short)" && cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && go vet ./... && go test ./... -count=1 The translate plugin implements HasSettings and not HasNavigation. Settings() returns the single locales link entry matching the PHP registerSettings values. The harness proves the entry appears on /settings for manage_locales admins only, is absent from /navigation, and that the singleton endpoints 404 for it. The README documents it. One sm-translate-plugin commit with no co-author trailer. go vet and go test are green in both repos.
<threat_model>
Trust Boundaries
| Boundary | Description |
|---|---|
| backend principal -> GET /settings | Metadata decides which settings entries, and which controller IDs, a principal may learn about |
| backend principal -> /settings/{code} endpoints | Singleton read and write endpoints keyed by a client-supplied code |
| plugin registration -> cabana registry | Compiled plugins declare settings entries that the registry trusts at boot |
| server metadata -> SPA router | The SPA turns a server-sent controller ID into a client route |
STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|---|---|---|---|---|---|
| T-quick-261007-p4y-01 | Information disclosure | cabana Registry.Metadata settings loop | medium | mitigate | A link entry is listed only when Allows(principal, item.Permissions) and r.canOpen(principal, item.Controller) both hold, so a principal never learns a controller ID it cannot open; covered by the restricted-principal case in settings_link_test.go |
| T-quick-261007-p4y-02 | Tampering / Denial of service | protectSetting via Registry.Setting | medium | mitigate | Registry.Setting returns false for link entries, so GET/PUT on a link code answer 404 before any nil Form or model factory is touched (no panic, no write); covered by the three 404 cases |
| T-quick-261007-p4y-03 | Spoofing / Tampering | compileContributions link branch | low | mitigate | Boot fails when a link names an unregistered controller, combines Controller with Form, NewModel or Model, or reuses a settings code; there is no runtime path to a dangling or ambiguous entry |
| T-quick-261007-p4y-04 | Tampering | SPA settingsPath | low | mitigate | The route comes only from controllerPath, which accepts exactly three [A-Za-z0-9_-] segments and otherwise falls back to /settings; no server string is interpolated into a URL unchecked |
| T-quick-261007-p4y-05 | Elevation of privilege | controller pages opened from Settings | low | accept | Opening a controller still goes through the existing controller guard (requiredOf) on every API call; the Settings link adds no access path |
| T-quick-261007-p4y-SC | Tampering | npm/go installs | low | accept | No new npm or Go dependency; the work uses the committed lockfile, the existing Vitest setup and the pinned swag in check-admin-openapi.sh |
| </threat_model> |
<success_criteria>
- The toggle renders a 44x26 track with a 20px knob and symmetric 3px insets.
- A plugin can declare a pact.SettingsItem that links to its admin controller. The Settings page shows it, permission- and controller-access-filtered, and opens the controller. The shell treats that controller as part of Settings. Singleton endpoints refuse it with 404. Existing singleton settings and other plugins are unaffected.
- The translate plugin's Locales screen is reached from Settings under Translate, as in WinterCMS, and no longer from the main navigation. </success_criteria>