Files
summercms/.planning/phases/10-admin-vue-spa/10-02-PLAN.md
2026-09-27 14:11:07 +02:00

45 KiB
Raw Blame History

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
10-admin-vue-spa 02 execute 2
10-01
pact/capabilities.go
phrasebook/lang.go
phrasebook/loader.go
phrasebook/translator.go
phrasebook/backend/lang/en/lang.yaml
phrasebook/backend/lang/pl/lang.yaml
phrasebook/phase10_test.go
cabana/relation_field.go
cabana/relation_field_test.go
cabana/form_schema.go
cabana/form_schema_test.go
cabana/list_schema.go
cabana/list_schema_test.go
cabana/filter_schema.go
cabana/relation.go
cabana/relation_test.go
cabana/registry.go
cabana/crud.go
cabana/http.go
cabana/messages.go
cabana/lang.go
cabana/messages_test.go
cabana/filter_options_test.go
cabana/openapi_conformance_test.go
cabana/admin_openapi.go
cabana/security_coverage_test.go
cabana/phase09_contract_test.go
internal/build/stubs/artifacts.tmpl
internal/tools/swagger2openapi/main.go
admin/openapi/admin.json
admin/src/api/schema.d.ts
admin/src/api/types.ts
admin/src/views/ListView.vue
admin/src/state/useAuth.ts
admin/src/state/useNavigation.ts
boardwalk/dist/**
../fonoteka.go/plugins/golem15/fonoteka/controllers/albums_admin_controller.go
../fonoteka.go/plugins/golem15/fonoteka/controllers/collections_admin_controller.go
../fonoteka.go/plugins/golem15/fonoteka/controllers/albums/config_list.yaml
../fonoteka.go/plugins/golem15/fonoteka/controllers/albums/config_form.yaml
../fonoteka.go/plugins/golem15/fonoteka/controllers/artists/config_list.yaml
../fonoteka.go/plugins/golem15/fonoteka/controllers/artists/config_form.yaml
../fonoteka.go/plugins/golem15/fonoteka/controllers/collections/config_list.yaml
../fonoteka.go/plugins/golem15/fonoteka/controllers/collections/config_form.yaml
../fonoteka.go/plugins/golem15/fonoteka/controllers/collections/config_relation.yaml
../fonoteka.go/plugins/golem15/fonoteka/controllers/genres/config_list.yaml
../fonoteka.go/plugins/golem15/fonoteka/controllers/genres/config_form.yaml
../fonoteka.go/plugins/golem15/fonoteka/controllers/styles/config_list.yaml
../fonoteka.go/plugins/golem15/fonoteka/controllers/styles/config_form.yaml
../fonoteka.go/plugins/golem15/fonoteka/lang/en/lang.yaml
../fonoteka.go/plugins/golem15/fonoteka/lang/pl/lang.yaml
../fonoteka.go/plugins/golem15/fonoteka/admin_phase10_relations_test.go
../fonoteka.go/plugins/golem15/fonoteka/admin_phase10_copy_test.go
../fonoteka.go/plugins/golem15/fonoteka/admin_phase10_controllers_test.go
../fonoteka.go/plugins/golem15/fonoteka/admin_phase10_auth_test.go
../fonoteka.go/plugins/golem15/fonoteka/admin_albums_test.go
../fonoteka.go/plugins/golem15/fonoteka/admin_collections_test.go
true
ADMIN-06
tokens raw_tokens tasks confidence
120000 120000 3 low
truths artifacts key_links prohibitions
Per D-17, GET {prefix}/api/v1/{vendor}/{plugin}/{controller}/fields/{field}/options?search=&page=&per_page= returns rows {value: numeric id, label: nameFrom value} with D-11 list meta, behind the controller permission, narrowed by the optional RelationExtendOptionsQuery hook; non-relation and read-only fields answer 404.
Per D-18, saving an album with {"genre": 3, "artists": [4, 9]} sets genre_id and replaces the album's artist pivot rows in array order inside the save transaction after FormBeforeCreate/FormBeforeUpdate; show, create and update responses carry the same shape in data plus meta.labels with {value, label} per relation field.
Per D-18 and Pitfall 6, a submitted relation id that the scoped options query would not return (unknown, out of scope, or duplicated) yields 422 validation_failed on that field and rolls back the whole save, including scalar changes.
Per D-26, the Collections owner field is served with readOnly true, its options endpoint answers 404, a submitted owner value never changes owner_id, and protectedFillKey and FormBeforeCreate are unchanged.
Per D-20, GET {prefix}/api/v1/lang (public) returns every backend::lang key for the request locale as a CLDR form map ({"other": ...} for plain strings) with meta.locale; a plugin implementing pact.HasLangOverrides can replace a key or add a locale without a Node rebuild.
Per D-13/D-24, config_list, config_form and config_relation accept an optional messages block of phrase keys whose texts use :count, :name and :term placeholders; every served schema carries a complete resolved messages object with defaults filled and plural keys as all their CLDR forms; an unknown messages key fails at boot.
Per D-14, toolbar.buttons is an ordered list of create and delete; the Winter string form (buttons: list_toolbar) fails at boot with a message naming the list syntax; delete without showCheckboxes, duplicates and unknown actions fail at boot; the five fonoteka controllers declare [create, delete].
Per D-27, a model-backed filter scope's choices come from the model's FilterOptions(scope) and are served at {prefix}/api/v1/{vendor}/{plugin}/{controller}/filters/{scope}/options behind the controller permission; a scope filter whose model lacks FilterOptions fails at boot.
Per D-15/D-16, every admin route in admin/openapi/admin.json has a typed success schema, jsonScalar and fieldContext are emitted as unions, and TestPhase10OpenAPIConformance decodes each real handler response into its documented Go type with unknown fields disallowed.
Per D-08, TestPhase10Controllers reads the list schema, list, form schema and one record of each of Albums, Artists, Collections, Genres and Styles through /plytadmin/api/v1 with a cookie, and the fields and columns match exactly what the tracked YAML declares.
statement verification
[flagged assumption A8] The artist pivot sort_order is set to the submitted array index; the API is not a parity surface. backstop
statement verification
[flagged decision] fonoteka implements no RelationExtendOptionsQuery because golem15_fonoteka_genres and golem15_fonoteka_artists carry no collection column; the hook is proven with cabana acme fixtures. backstop
statement verification
[flagged decision] Required relation fields keep Phase 9 decision 304: required is a schema hint rendered by the SPA, not a save-time rule, matching the PHP Album rules. backstop
path provides
cabana/relation_field.go FieldRelationContract compile, options query, id revalidation, FK assignment, ordered pivot sync and labels
path provides
cabana/messages.go Typed list/form/relation messages blocks with framework defaults and CLDR form resolution
path provides
cabana/lang.go Public backend::lang string bundle handler
path provides
phrasebook/backend/lang/pl/lang.yaml Framework Polish admin strings
path provides
cabana/openapi_conformance_test.go Doc-versus-wire conformance for every admin route
path provides
../fonoteka.go/plugins/golem15/fonoteka/admin_phase10_controllers_test.go Five-controller assembled contract through the prefix
from to via pattern
cabana/crud.go cabana/relation_field.go save applies present relation keys after the Before hook and before the row write; show projects values and labels FieldRelation
from to via pattern
cabana/relation_field.go pact.RelationExtendOptionsQuery the same scoped query serves options and revalidates submitted ids RelationExtendOptionsQuery
from to via pattern
phrasebook/translator.go cabana/lang.go Translator.Bundle(locale, "backend::lang.") of Forms maps Bundle
from to via pattern
cabana/admin_openapi.go admin/src/api/schema.d.ts scripts/check-admin-openapi.sh, run at the end of each of Tasks 1-3 so every task commits a drift-clean document and types Envelope
[flagged-unverified] A relation save must not write a foreign key in the protected fill-key set, and must not attach a related row that the scoped options query would not return.
[flagged-unverified] The public string bundle must not expose any namespace other than backend::lang.
[flagged-unverified] Framework code must not name a plugin table, pivot, foreign key or label column; they come only from the controller's field relation contract.
[flagged-unverified] Existing Phase 9 security assertions (no collection_id or user_id leak, owner forced by FormBeforeCreate) must not be weakened to make relation JSON pass.

Phase Goal

As a backend administrator, I want to open my project's own admin URL, log in and manage Albums, Artists, Collections, Genres and Styles through schema-driven lists, forms and the relation manager, so that I can administer the catalogue from one Go binary without the WinterCMS backend.

Grow the `cabana` admin API into the full contract the SPA screens need: relation field options and relation saves with labels, the framework `backend::lang` strings with an override layer and a public bundle, per-controller `messages`, the declarative toolbar, model-backed filter options, and a fully typed OpenAPI document proven against the wire. Update fonoteka's YAML, controllers and lang to the new contract.

Purpose: Plans 10-03 and 10-04 render only what this API serves, so every rule (scoping, read-only owner, plural copy, toolbar actions, filter choices) must be enforced and typed here. Decisions implemented: D-08, D-13, D-14 (costly), D-15, D-16, D-17, D-18, D-20, D-24, D-26, D-27; D-28 fixes this plan's scope. Output: new cabana files and routes, phrasebook backend namespace and override layer, regenerated admin OpenAPI and TS types, fonoteka YAML/lang/controller updates and assembled tests.

Repos: every task writes summercms.go and fonoteka.go. Commit per repo; planning docs and code in separate commits; never add co-author tags.

Shared OpenAPI files across tasks: cabana/http.go and cabana/admin_openapi.go are hand-edited and grow additively (Task 1 adds the field options route and RecordEnvelope; Task 2 adds /lang and the messages/toolbarButtons/redirects schema fields; Task 3 adds the filter options route and types every remaining route). admin/openapi/admin.json and admin/src/api/schema.d.ts are never hand-edited: each task that changes an annotation or a documented Go type regenerates them with scripts/check-admin-openapi.sh, commits them in the same commit as that change, and ends drift-clean (scripts/check-admin-openapi.sh --check prints no diff) before the next task starts.

<execution_context> @/.claude/gsd-core/workflows/execute-plan.md @/.claude/gsd-core/templates/summary.md </execution_context>

@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/10-admin-vue-spa/10-CONTEXT.md @.planning/phases/10-admin-vue-spa/10-RESEARCH.md @.planning/phases/10-admin-vue-spa/10-01-SUMMARY.md @cabana/relation.go @cabana/crud.go @cabana/form_schema.go @cabana/list_schema.go @cabana/filter_schema.go @phrasebook/loader.go @phrasebook/translator.go @pact/capabilities.go @lagoon/relations.go From Plan 10-01: `service.apiBase()`, `adminAPI(rel)` test helpers in cabana and fonoteka, `Envelope[T]`, `ListEnvelope[T]`, `AdminRecord`, `scripts/check-admin-openapi.sh [--check]`, CSRF wrapper on unsafe routes, cookie `summer_admin`. Existing Phase 9 contracts: `RelationContract` and `AdminRelationContractProvider` (relation manager), `pact.ListRelationColumnMapper`, `pact.FilterScope{FilterScopes(); FilterScope(name, db, value)}` on the model, `pact.DropdownOptionsProvider`, `normalizeIDs`, `escapeLike`, `modelColumns`, `protectedFillKey`, `formBeforeCreate/Update`, `lagoon.Fill`, `lagoon.Validate`, `CRUDService.Show/Create/Update`, `phrasebook.Catalog.Load(namespace, fs)`, `entry{text, plurals, pipes}`, `interpolate` with `:name`, `:Name`, `:NAME`. Phase 5 join-table contract (lagoon/relations.go): pivot writes are explicit delete then bulk insert in the parent's transaction; never GORM Association().Replace().

Artifacts this phase produces

  • pact.RelationExtendOptionsQuery{ RelationExtendOptionsQuery(ctx, field string, db *gorm.DB) *gorm.DB }, pact.HasLangOverrides{ LangOverridesFS() fs.FS } (layout lang/<locale>/<namespace>/<group>.yaml), pact.FilterOptions{ FilterOptions(scope string) []Option }
  • cabana.FieldRelationContract{Field, Kind, NewRelated, ForeignKey, NewPivot, ParentForeignKey, RelatedForeignKey, OrderColumn, LabelColumn}, cabana.FieldRelationProvider{ AdminFieldRelations() []FieldRelationContract }, cabana.RelationOption{Value uint; Label string}, cabana.RecordMeta{Labels map[string][]RelationOption}, cabana.RecordEnvelope
  • FormField.Multiple (multiple), FormField.ReadOnly (readOnly); FormView.Messages, FormView.Redirects; ListSchema.Messages; RelationSchema.Messages
  • Routes: GET /{vendor}/{plugin}/{controller}/fields/{field}/options, GET /{vendor}/{plugin}/{controller}/filters/{scope}/options, GET /lang (public)
  • phrasebook namespace backend (phrasebook/backend/lang/{en,pl}/lang.yaml), (*Catalog).Override, (*Translator).Forms(locale, key) (map[string]string, bool), (*Translator).Bundle(locale, prefix string) map[string]map[string]string
  • messages vocabulary: list recordCount, create, searchPrompt, empty, emptySearch, emptySearchHint, selected, deleteSelected, deleteConfirm, deleted; form create, update, saved, deleteConfirm, deleted; relation link, linkHint, candidateSearch, linked, unlinkSelected, unlinkConfirm, unlinked, empty
  • Toolbar compile of toolbar.buttons list with boot errors; scaffold stub emits the list syntax
  • OpenAPI converter union rewrite for cabana.jsonScalar and cabana.fieldContext
  • fonoteka: AdminFieldRelations() on albums (genre, artists) and collections (owner); messages and toolbar.buttons in all five configs; new lang keys; tests TestPhase10AlbumRelations, TestPhase10CollectionOwnerReadOnly, TestPhase10ControllerCopy, TestPhase10Controllers
  • cabana tests TestPhase10RelationOptions, TestPhase10RelationSave, TestPhase10RelationForgedID, TestPhase10RelationBoot, TestPhase10Messages, TestPhase10Toolbar, TestPhase10Bundle, TestPhase10FilterOptions, TestPhase10OpenAPIConformance; phrasebook TestPhase10Forms, TestPhase10LangOverride, TestPhase10SPAKeysResolve
Task 1: An admin picks an album's genre and artists from the options endpoint, saves them and reads them back with labels pact/capabilities.go, cabana/relation_field.go, cabana/relation_field_test.go, cabana/form_schema.go, cabana/form_schema_test.go, cabana/registry.go, cabana/crud.go, cabana/http.go, cabana/admin_openapi.go, cabana/security_coverage_test.go, cabana/phase09_contract_test.go, admin/openapi/admin.json, admin/src/api/schema.d.ts, ../fonoteka.go/plugins/golem15/fonoteka/controllers/albums_admin_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/collections_admin_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/admin_phase10_relations_test.go, ../fonoteka.go/plugins/golem15/fonoteka/admin_albums_test.go, ../fonoteka.go/plugins/golem15/fonoteka/admin_collections_test.go cabana/relation.go, cabana/crud.go, cabana/form_schema.go, cabana/registry.go, cabana/http.go, cabana/query.go, cabana/admin_openapi.go, pact/capabilities.go, lagoon/relations.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/albums_admin_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/collections_admin_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/models/album.go, ../fonoteka.go/plugins/golem15/fonoteka/models/album_artist.go, ../fonoteka.go/plugins/golem15/fonoteka/models/collection.go, ../fonoteka.go/plugins/golem15/fonoteka/models/album/fields.yaml, ../fonoteka.go/plugins/golem15/fonoteka/models/collection/fields.yaml, .planning/phases/10-admin-vue-spa/10-RESEARCH.md (Pattern 5, Pitfalls 6 and 13) - TestPhase10RelationOptions (acme fixtures, real PostgreSQL): search is case-insensitive on the label column with LIKE metacharacters escaped; order is label then id; per_page defaults to 20 and caps at 100; RelationExtendOptionsQuery narrows the rows; values are numbers; a principal without the controller permission gets 403 before SQL; a non-relation field and a read-only field get 404. - TestPhase10RelationSave: belongsTo sets the foreign key; belongsToMany replaces pivot rows in submitted order with OrderColumn equal to the index; a missing key leaves the relation unchanged; null clears a nullable belongsTo; show returns ids plus meta.labels. - TestPhase10RelationForgedID: an id outside the scoped query, a non-integer id and a duplicated id each return 422 on that field and nothing (scalar or pivot) is committed. - TestPhase10RelationBoot: a relation field without a contract, a contract naming a missing column, and an unknown kind each fail activation naming plugin, controller and field. - TestPhase10AlbumRelations (fonoteka, assembled, cookie through /plytadmin): options for genre and artists, create and update with genre and ordered artists, show with labels, forged artist id 422. - TestPhase10CollectionOwnerReadOnly: owner is readOnly in the form schema, owner options is 404, a submitted owner id does not change owner_id, show carries the owner label. Start with failing `TestPhase10AlbumRelations` and `TestPhase10RelationSave`, then implement D-17, D-18 and D-26 in `cabana/relation_field.go`, extending the existing model-owned relation style (framework code never guesses a table or key).

Contract: add FieldRelationContract{Field string; Kind string ("belongsTo" or "belongsToMany"); NewRelated func() any; ForeignKey string (belongsTo column on the parent); NewPivot func() any; ParentForeignKey, RelatedForeignKey string (belongsToMany pivot columns); OrderColumn string (optional pivot column set to the array index); LabelColumn string (physical label column; default = the field's nameFrom mapped through pact.ListRelationColumnMapper when the controller implements it)} and FieldRelationProvider{ AdminFieldRelations() []FieldRelationContract } on the controller. At activation, every type: relation field must have exactly one contract; kinds, related/pivot models and every named column are validated with modelColumns; any failure is a boot error naming plugin, controller and field. Compile FormField.Multiple (json multiple, belongsToMany) and FormField.ReadOnly (json readOnly, a belongsTo whose ForeignKey is in protectedFillKey, per D-26; the fill-key list and FormBeforeCreate stay as they are).

Options (D-17): add pact.RelationExtendOptionsQuery and mount GET {apiBase}/{vendor}/{plugin}/{controller}/fields/{field}/options with a field constraint of [A-Za-z_][A-Za-z0-9_]*, served through protect; answer 404 not_found for a field that is not a writable relation. Query the related model, apply the hook when the controller implements it, filter search with ILIKE on the label column using escapeLike, order by label then primary key, paginate with the Phase 9 relation limits (default 20, max 100, reuse the relation query normalization), and write ListEnvelope[[]RelationOption] where RelationOption{Value uint json:"value"; Label string json:"label"}.

Save (D-18): before scalar projection, lift the keys of writable relation fields out of the body (other nested values are still dropped); only keys present in the body are applied. Inside the existing save transaction, after formBeforeCreate/formBeforeUpdate: normalize ids with normalizeIDs, reject duplicates, and re-run the same scoped options query with WHERE <primary key> IN (...); any id it does not return is a 422 validation_failed on that field (the transaction rolls back everything). A belongsTo value (or null for a nullable FK) is assigned to the parent's FK field before tx.Create/tx.Save; a belongsToMany value is written after the row exists: delete the parent's pivot rows, then bulk insert one pivot model per id in submitted order with OrderColumn set to the index when declared (Phase 5 contract; never Association Replace). Then the After hooks run. Required relation fields keep Phase 9 decision 304 (schema hint only), matching the PHP Album rules. Show, Create and Update return the record with relation values in data (belongsTo id or null; belongsToMany ids in pivot order, then primary key) and meta.labels as field to []RelationOption (read-only fields included); document them as RecordEnvelope{Data AdminRecord; Meta RecordMeta}.

fonoteka: albums controller declares genre (belongsTo, models.Genre, ForeignKey genre_id, LabelColumn name) and artists (belongsToMany, models.Artist via models.AlbumArtist, ParentForeignKey album_id, RelatedForeignKey artist_id, OrderColumn sort_order, LabelColumn name); collections controller declares owner (belongsTo, the user model, ForeignKey owner_id, LabelColumn email). fonoteka implements no RelationExtendOptionsQuery (genres and artists have no collection column); say so in a comment. Adjust Phase 9 album and collection tests only where they pin the old relation-field JSON or the absence of relation keys; keep every collection_id/user_id/owner assertion.

Add the two routes to the admin route inventory, annotate them in cabana/admin_openapi.go (the research's AdminFieldOptions example; show/create/update as RecordEnvelope), and run scripts/check-admin-openapi.sh to regenerate the committed document and types.

Regeneration step (end of task): after the last annotation or documented-type edit, run scripts/check-admin-openapi.sh once more, commit the regenerated admin/openapi/admin.json and admin/src/api/schema.d.ts together with this task's cabana changes, and confirm scripts/check-admin-openapi.sh --check prints no diff; the task ends drift-clean. go test ./cabana -run '^TestPhase10Relation(Options|Save|ForgedID|Boot)$|^TestPhase09(PermissionMatrix|ContractInventory)$' -count=1 -v && (cd ../fonoteka.go && go test ./plugins/golem15/fonoteka -run '^(TestPhase10AlbumRelations|TestPhase10CollectionOwnerReadOnly|TestAlbumsAdmin.|TestCollectionsAdmin.)$' -count=1) && scripts/check-admin-openapi.sh --check <fails_when>Any command exits non-zero; the cabana output lacks a "--- PASS" line for each of the four TestPhase10Relation tests or shows "no tests to run" or SKIP; the fonoteka run prints FAIL or "no tests to run"; check-admin-openapi.sh prints a diff.</fails_when> <acceptance_criteria> - All six behaviors above pass against real PostgreSQL; Phase 9 album and collection suites still pass. - grep -c 'fields/{field}/options' cabana/http.go prints 1 and grep -c 'Association(' cabana/relation_field.go prints 0. - python3 -c "import json;d=json.load(open('admin/openapi/admin.json'));assert '/{vendor}/{plugin}/{controller}/fields/{field}/options' in d['paths']" exits 0. - grep -rniE 'golem15|album' cabana/relation_field.go prints nothing. </acceptance_criteria> An admin can fetch genre and artist choices, save an album's genre and ordered artists, and read them back with labels; forged or out-of-scope ids are rejected and the Collections owner cannot be reassigned.

Task 2: Controllers speak their own copy through backend strings, messages and the declarative toolbar pact/capabilities.go, phrasebook/lang.go, phrasebook/loader.go, phrasebook/translator.go, phrasebook/backend/lang/en/lang.yaml, phrasebook/backend/lang/pl/lang.yaml, phrasebook/phase10_test.go, cabana/messages.go, cabana/lang.go, cabana/list_schema.go, cabana/list_schema_test.go, cabana/form_schema.go, cabana/relation.go, cabana/relation_test.go, cabana/http.go, cabana/admin_openapi.go, cabana/messages_test.go, cabana/security_coverage_test.go, internal/build/stubs/artifacts.tmpl, admin/openapi/admin.json, admin/src/api/schema.d.ts, ../fonoteka.go/plugins/golem15/fonoteka/controllers/albums/config_list.yaml, ../fonoteka.go/plugins/golem15/fonoteka/controllers/albums/config_form.yaml, ../fonoteka.go/plugins/golem15/fonoteka/controllers/artists/config_list.yaml, ../fonoteka.go/plugins/golem15/fonoteka/controllers/artists/config_form.yaml, ../fonoteka.go/plugins/golem15/fonoteka/controllers/collections/config_list.yaml, ../fonoteka.go/plugins/golem15/fonoteka/controllers/collections/config_form.yaml, ../fonoteka.go/plugins/golem15/fonoteka/controllers/collections/config_relation.yaml, ../fonoteka.go/plugins/golem15/fonoteka/controllers/genres/config_list.yaml, ../fonoteka.go/plugins/golem15/fonoteka/controllers/genres/config_form.yaml, ../fonoteka.go/plugins/golem15/fonoteka/controllers/styles/config_list.yaml, ../fonoteka.go/plugins/golem15/fonoteka/controllers/styles/config_form.yaml, ../fonoteka.go/plugins/golem15/fonoteka/lang/en/lang.yaml, ../fonoteka.go/plugins/golem15/fonoteka/lang/pl/lang.yaml, ../fonoteka.go/plugins/golem15/fonoteka/admin_phase10_copy_test.go, ../fonoteka.go/plugins/golem15/fonoteka/admin_phase10_auth_test.go phrasebook/loader.go, phrasebook/translator.go, phrasebook/lang.go, cabana/list_schema.go, cabana/form_schema.go, cabana/relation.go, cabana/schema.go, cabana/http.go, internal/build/stubs/artifacts.tmpl, /media/nvme/dev/golem15/fonoteka/modules/backend/lang/pl/lang.php, /media/nvme/dev/golem15/fonoteka/modules/backend/lang/en/lang.php, .planning/phases/10-admin-vue-spa/design/README.md (all copy), .planning/phases/10-admin-vue-spa/10-RESEARCH.md (Pattern 6, Pitfalls 4 and 5), ../fonoteka.go/plugins/golem15/fonoteka/controllers/albums/config_list.yaml, ../fonoteka.go/plugins/golem15/fonoteka/controllers/collections/config_relation.yaml - TestPhase10Forms: plain text maps to {"other": text}; a YAML plural map copies all CLDR forms; category-only pipes map by category; an exact or range pipe is not convertible (ok false). - TestPhase10LangOverride: an override FS replaces an existing backend key, adds a key, and adds a new locale; a malformed override path fails activation; a backend key that cannot convert to forms (exact or range pipe) fails activation naming the key. - TestPhase10Bundle: GET {prefix}/api/v1/lang without auth returns only backend::lang keys for the Accept-Language locale with fallback to app.fallback_locale, meta.locale set, Cache-Control no-cache; no plugin or lagoon key appears. - TestPhase10Messages: omitted keys are filled with framework defaults; a plugin key resolves in pl and en; plural keys arrive as all forms; an unknown messages key fails at boot through DisallowUnknownField; a messages value naming a missing phrase key fails at boot when a translator is available. - TestPhase10Toolbar: [create, delete] compiles in order; buttons: list_toolbar fails with a message containing "toolbar.buttons must be a list"; delete without showCheckboxes, duplicates and unknown actions fail; create is omitted from the served list when the controller has no compiled form. - TestPhase10ControllerCopy (fonoteka, assembled): each of the five list schemas serves toolbarButtons ["create","delete"] and a complete Polish messages object; the album form serves create, update and saved; the editors relation schema serves link and linked with Polish plural forms. - TestPhase10SPAKeysResolve: every backend::lang key literal found in admin/src resolves in both pl and en. (1) Backend strings, per D-20: add `phrasebook/backend/lang/{en,pl}/lang.yaml` embedded by `phrasebook/lang.go` and loaded in `Activate` as namespace `backend` right after `lagoon`, so keys resolve as `backend::lang..`. Keep Winter key names where Winter has them (list.search_prompt, list.no_records, list.delete_selected, list.loading, list.prev_page, list.next_page, list.records_per_page, list.column_switch_true, list.column_switch_false, form.save, form.save_and_close, form.cancel, form.delete, form.none, form.update, form.create, form.return_to_list, form.close, relation.add, relation.link, relation.unlink, relation.remove) and add the SPA's own keys under auth.*, nav.*, list.*, form.*, relation.*, messages.* for every string in the design README (login copy, navigation aria labels, pagination range `:from–:to z :total`, results count, empty and empty-search states, selection pill, 422 banner, unsupported field `Nieobsługiwany typ pola: :type`, toast close, modal copy, Dodaj (:count)). Polish values follow the design README copy (D-06); English values are plain equivalents. Every plural text is a YAML CLDR map (one, few, many, other for pl; one, other for en), never a pipe string. Placeholders use `:count`, `:name`, `:term` per D-24. Framework message defaults per D-13: list create "Nowy rekord", deleteConfirm "Usunąć zaznaczone (:count)?", form saved "Zapisano", plus defaults for every other vocabulary key.

(2) Override layer and bundle: add pact.HasLangOverrides and (*Catalog).Override(owner string, fsys fs.FS) error reading lang/<locale>/<namespace>/<group>.yaml; it runs after every namespace is loaded, may replace existing keys and add locales, and fails on malformed paths. After overrides, Activate fails when any backend:: key cannot convert to forms. Add (*Translator).Forms(locale, key string) (map[string]string, bool) (text → other; plural map → copy; category-only pipes → by category; exact/range pipes → false) and (*Translator).Bundle(locale, prefix string) map[string]map[string]string merging fallback-locale keys under requested-locale keys. Add cabana/lang.go and mount public GET {apiBase}/lang next to the auth routes (no guard; GET needs no CSRF header): data is the bundle for backend::lang., meta.locale is the resolved locale, header Cache-Control: no-cache; document it as Envelope[map[string]map[string]string].

(3) Messages, per D-13/D-24: cabana/messages.go defines fixed structs listMessages{RecordCount, Create, SearchPrompt, Empty, EmptySearch, EmptySearchHint, Selected, DeleteSelected, DeleteConfirm, Deleted}, formMessages{Create, Update, Saved, DeleteConfirm, Deleted} and relationMessages{Link, LinkHint, CandidateSearch, Linked, UnlinkSelected, UnlinkConfirm, Unlinked, Empty} with lowerCamel yaml tags, decoded strictly so an unknown key fails at boot. config_list.yaml and config_form.yaml gain messages:; config_relation.yaml gains messages: per relation (applying the D-13 rules to relation copy; Claude's discretion on vocabulary). Omitted keys take the framework default key; the list searchPrompt default is the existing toolbar.search.prompt when set. At activation, when a translator is published, every message key must exist in the catalog. Cached schemas keep keys; each response localizes to messages as key → CLDR form map (via Forms). FormView also serves redirects with the raw Winter create.redirect, create.redirectClose, update.redirect, update.redirectClose strings (the SPA maps them in Plan 10-03).

(4) Toolbar, per D-14: listToolbar.Buttons becomes an ordered list type with a custom UnmarshalYAML(ast.Node) (the fieldMap/scopeMap pattern) that rejects a scalar with toolbar.buttons must be a list of actions (create, delete); the Winter partial "list_toolbar" is not supported; accepted actions are exactly create and delete; duplicates, unknown actions and delete without showCheckboxes: true fail at boot. The compiled ToolbarButtons keeps declared order and omits create when the controller has no compiled form. Update the inline YAML fixtures in cabana/list_schema_test.go and the scaffold stub in internal/build/stubs/artifacts.tmpl (config_list gains toolbar.buttons: [create] and a search prompt) to the list syntax.

(5) fonoteka: set toolbar.buttons: [create, delete] in the five config_list.yaml files (each has showCheckboxes: true; keep toolbar.search.prompt), add messages blocks to the five list and form configs and to the editors relation, and add the referenced keys to lang/{pl,en}/lang.yaml under each existing group (item, artist, collection, genre, style) with natural Polish plurals (for example albums recordCount ":count pozycja w katalogu" / ":count pozycje w katalogu" / ":count pozycji w katalogu"; create "Nowy album"; searchPrompt "Szukaj albumów…"; editors link "Dodaj edytora" and linked "Dodano :count edytora" / "Dodano :count edytorów", matching the plugin's existing "Edytorzy" wording). Extend TestPhase10LangCatalog to also resolve every backend::lang key referenced by fonoteka YAML.

(6) Write the tests in <behavior> (phrasebook/phase10_test.go including TestPhase10SPAKeysResolve walking ../admin/src, cabana/messages_test.go, fonoteka admin_phase10_copy_test.go), add /lang to the route inventory as public, and regenerate the admin document and types with scripts/check-admin-openapi.sh.

Regeneration step (end of task): the new /lang annotation and the messages, toolbarButtons and redirects fields on ListSchema, FormView and RelationSchema change the document, so after the last such edit run scripts/check-admin-openapi.sh once more, commit the regenerated admin/openapi/admin.json and admin/src/api/schema.d.ts together with this task's cabana and phrasebook changes, and confirm scripts/check-admin-openapi.sh --check prints no diff; the task ends drift-clean. go test ./phrasebook ./cabana -run '^TestPhase10(Forms|LangOverride|SPAKeysResolve|Bundle|Messages|Toolbar)$|^TestPhase09(PermissionMatrix|ContractInventory)$' -count=1 -v && go test ./internal/build -run '^Test.*AdminController' -count=1 && (cd ../fonoteka.go && go test ./plugins/golem15/fonoteka -run '^(TestPhase10ControllerCopy|TestPhase10LangCatalog)$' -count=1 -v) && scripts/check-admin-openapi.sh --check <fails_when>Any command exits non-zero; output shows "no tests to run" or SKIP for a named Phase 10 test, or lacks a "--- PASS" line for each of TestPhase10Forms, TestPhase10LangOverride, TestPhase10SPAKeysResolve, TestPhase10Bundle, TestPhase10Messages, TestPhase10Toolbar, TestPhase10ControllerCopy and TestPhase10LangCatalog; check-admin-openapi.sh prints a diff.</fails_when> <acceptance_criteria> - grep -rn 'list_toolbar' ../fonoteka.go/plugins/golem15/fonoteka/controllers internal/build/stubs prints nothing and grep -c 'buttons: \[create, delete\]' ../fonoteka.go/plugins/golem15/fonoteka/controllers/*/config_list.yaml prints 1 for each of the five files.

- Activation converts every `backend::` key to CLDR forms (a pipe-plural backend string fails boot), and TestPhase10LangOverride proves an unconvertible backend key fails activation naming the key.
- Every named behavior test passes; the bundle response contains no key outside `backend::lang.`.

</acceptance_criteria> Each controller's list, form and relation schema arrives with complete, locale-resolved copy and a declarative toolbar, and the SPA can load every framework string from one public bundle.

Task 3: Filters get their choices and the whole admin API is typed and proven against the wire pact/capabilities.go, cabana/filter_schema.go, cabana/http.go, cabana/admin_openapi.go, cabana/filter_options_test.go, cabana/openapi_conformance_test.go, cabana/list_schema_test.go, cabana/security_coverage_test.go, cabana/phase09_contract_test.go, internal/tools/swagger2openapi/main.go, admin/openapi/admin.json, admin/src/api/schema.d.ts, admin/src/api/types.ts, admin/src/views/ListView.vue, admin/src/state/useAuth.ts, admin/src/state/useNavigation.ts, boardwalk/dist/**, ../fonoteka.go/plugins/golem15/fonoteka/admin_phase10_controllers_test.go cabana/filter_schema.go, cabana/query.go, cabana/admin_openapi.go, cabana/schema_types.go, cabana/settings.go, cabana/navigation.go, cabana/relation.go, internal/tools/swagger2openapi/main.go, cabana/testdata/list/all_filters.yaml, cabana/list_schema_test.go, admin/src/api/types.ts, admin/src/views/ListView.vue, .planning/phases/10-admin-vue-spa/10-RESEARCH.md (Pattern 4, Pitfalls 7 and 8) - TestPhase10FilterOptions: a scope filter serves the model's FilterOptions(scope) as [{value, label}] with labels localized; an undeclared scope name is 404; a principal without the controller permission is 403 before the provider runs; a scope filter whose model lacks FilterOptions fails activation. - TestPhase10OpenAPIConformance: for every route in the admin inventory, the real handler response (httptest against a fixture registry) decodes into the Go type its annotation documents with DisallowUnknownFields, and every documented path exists in admin/openapi/admin.json with that schema reference. - TestPhase10Controllers (fonoteka, assembled, cookie through /plytadmin): for Albums, Artists, Collections, Genres and Styles the list schema columns equal the tracked columns.yaml keys in order, the form schema fields equal the tracked fields.yaml keys in order, every served field type is one of the eight D-05 built-ins (text, textarea, number, checkbox, switch, dropdown, relation, relation-manager) so no real screen falls back to the unsupported box, the list returns data and meta, and one created record is readable with the same keys. (1) Filter choices, per D-27: add `pact.FilterOptions{ FilterOptions(scope string) []Option }` implemented by the model (the same model that implements `pact.FilterScope`). At activation a `type: scope` filter whose model lacks it fails with an error naming the scope and D-27; update the acme fixture model used by `cabana/list_schema_test.go` to implement it. Mount `GET {apiBase}/{vendor}/{plugin}/{controller}/filters/{scope}/options` through `protect` with a `scope` identifier constraint; 404 for a name that is not a declared scope filter of that controller's list; data is `[]FilterOption{Value string; Label string}` with labels passed through the translator; meta.locale.

(2) Full typing, per D-15/D-16: give every remaining admin route a concrete response type in cabana/admin_openapi.go: settings list Envelope[[]SettingsEntry], settings schema and form schema Envelope[FormView], settings GET/PUT Envelope[SettingsResult], relation schema Envelope[RelationSchema], relation linked/candidates ListEnvelope[[]AdminRecord], link/unlink Envelope[RelationMutationResult], bulk delete and delete Envelope[BulkResult], logout Envelope[AdminLogoutData], filter options Envelope[[]FilterOption]; every protected route documents 401, 403 and 404, write routes document 422, CSRF-protected routes document 403. Add --requiredByDefault-friendly omitempty only where a field is genuinely optional. In internal/tools/swagger2openapi/main.go rewrite the component for cabana.jsonScalar to a nullable oneOf of string, number and boolean, and cabana.fieldContext to a oneOf of string and array of string. Remove the untyped SuccessEnvelope from annotations once nothing references it. Regenerate with scripts/check-admin-openapi.sh.

(3) Conformance: cabana/openapi_conformance_test.go builds a fixture registry (acme names only), calls every inventoried handler with httptest (using the existing Testcontainers PostgreSQL helper where rows are needed), decodes each body into the documented Go envelope type with DisallowUnknownFields, and cross-checks the path and schema reference in admin/openapi/admin.json, so the document cannot drift from the wire. Keep TestPhase09PermissionMatrix and TestPhase09ContractInventory green with the new routes (options, filters, lang).

(4) SPA stays green: update admin/src/api/types.ts aliases and any call site in ListView.vue, useAuth.ts and useNavigation.ts whose types changed so npm --prefix admin run typecheck passes with no casts to any; rebuild boardwalk/dist only if the built output changed.

(5) fonoteka admin_phase10_controllers_test.go implements TestPhase10Controllers (D-08: fields and columns are exactly the tracked YAML; no mock-only fields).

Regeneration step (end of task): steps (1) and (2) change the document and the converter output, so after the last annotation, documented-type or converter edit run scripts/check-admin-openapi.sh once more, commit the regenerated admin/openapi/admin.json and admin/src/api/schema.d.ts together with the step (4) SPA updates that consume them, and confirm scripts/check-admin-openapi.sh --check prints no diff; the task (and the plan) ends drift-clean. go test ./cabana -run '^TestPhase10(FilterOptions|OpenAPIConformance)$|^TestPhase09(PermissionMatrix|ContractInventory)$' -count=1 -v && scripts/check-admin-openapi.sh --check && npm --prefix admin run typecheck && scripts/check-admin-dist.sh && (cd ../fonoteka.go && go test ./plugins/golem15/fonoteka -run '^TestPhase10Controllers$' -count=1 -v) && go vet ./... && go test ./... && (cd ../fonoteka.go && go vet ./... && go test ./...) <fails_when>Any command exits non-zero; a named test is missing its "--- PASS" line or shows "no tests to run" or SKIP; either drift script prints a diff; vue-tsc reports an error.</fails_when> <acceptance_criteria> - python3 -c "import json;d=json.load(open('admin/openapi/admin.json'));s=json.dumps(d);assert 'cabana.SuccessEnvelope' not in s and '/{vendor}/{plugin}/{controller}/filters/{scope}/options' in d['paths'] and '/lang' in d['paths']" exits 0. - grep -rn 'as any' admin/src prints nothing. - TestPhase10OpenAPIConformance covers every route in the admin inventory (the test fails when a route has no conformance case). - Both repositories pass go vet ./... and go test ./.... </acceptance_criteria> Filter bars can fetch model-backed choices, and every admin endpoint the SPA calls has a generated TypeScript type that is proven to match what the handler writes.

<threat_model>

Trust Boundaries

Boundary Description
SPA → options/filters endpoints Untrusted search, paging and field/scope names select related rows
SPA → record save Untrusted relation ids and keys become foreign keys and pivot rows
Anonymous browser → /lang Unauthenticated callers read the string bundle
Plugin YAML/override FS → boot Plugin-supplied copy and toolbar declarations shape every schema

STRIDE Threat Register

Threat ID Category Component Severity Disposition Mitigation Plan
T-10-09 Elevation of Privilege cabana relation save (IDOR via relation ids) high mitigate Submitted ids are revalidated through the same RelationExtendOptionsQuery-scoped query inside the save transaction; unknown, out-of-scope and duplicate ids are 422 and roll back; TestPhase10RelationForgedID and TestPhase10AlbumRelations.
T-10-10 Tampering belongs-to mapping of protected foreign keys (mass assignment) high mitigate D-26: a belongsTo whose FK is a protected fill key is readOnly, never written, and its options endpoint is 404; protectedFillKey and FormBeforeCreate unchanged; TestPhase10CollectionOwnerReadOnly.
T-10-11 Information Disclosure fields/{field}/options enumeration medium mitigate Served through protect (controller permission before SQL), scoped by the hook, per_page capped at 100, 404 for non-relation and read-only fields (no user-email enumeration via owner); TestPhase10RelationOptions.
T-10-12 Information Disclosure public /lang bundle low mitigate Bundle limited to the backend::lang prefix; TestPhase10Bundle asserts no other namespace appears.
T-10-13 Tampering messages and toolbar YAML low mitigate Strict decoding with unknown-key rejection, custom toolbar unmarshal, boot-time key existence checks; TestPhase10Messages and TestPhase10Toolbar.
T-10-14 Tampering OpenAPI document versus handler output medium mitigate TestPhase10OpenAPIConformance decodes every handler response into its documented type with unknown fields disallowed; check-admin-openapi.sh --check guards drift.
T-10-15 Elevation of Privilege filters/{scope}/options medium mitigate protect() before the provider, scope names allow-listed against the compiled list filters, 404 otherwise; TestPhase10FilterOptions.
T-10-SC Tampering npm/Go dependencies high mitigate No new npm or Go package; admin/ uses npm ci against the lockfile approved in Plan 10-01; swag stays pinned at v1.16.6 via go run.
</threat_model>
Run `go vet ./... && go test ./...` in summercms.go and `(cd ../fonoteka.go && go vet ./... && go test ./...)`, then `scripts/check-admin-openapi.sh --check`, `scripts/check-admin-dist.sh` and `npm --prefix admin run typecheck`. A non-zero exit, a skipped PostgreSQL Phase 10 test, or a printed diff fails the plan.

<success_criteria>

  • Relation choices, relation saves with labels, the read-only owner, messages, the toolbar, the string bundle and filter choices behave as specified and are covered by named tests.
  • The admin OpenAPI document is fully typed and proven against the wire; the SPA still typechecks against it.
  • The five fonoteka controllers serve exactly their tracked YAML through the prefix with complete Polish copy. </success_criteria>
Create `.planning/phases/10-admin-vue-spa/10-02-SUMMARY.md` when done.