45 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 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 10-admin-vue-spa | 02 | execute | 2 |
|
|
true |
|
|
|
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>
Artifacts this phase produces
pact.RelationExtendOptionsQuery{ RelationExtendOptionsQuery(ctx, field string, db *gorm.DB) *gorm.DB },pact.HasLangOverrides{ LangOverridesFS() fs.FS }(layoutlang/<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.RecordEnvelopeFormField.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) phrasebooknamespacebackend(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]stringmessagesvocabulary: listrecordCount, create, searchPrompt, empty, emptySearch, emptySearchHint, selected, deleteSelected, deleteConfirm, deleted; formcreate, update, saved, deleteConfirm, deleted; relationlink, linkHint, candidateSearch, linked, unlinkSelected, unlinkConfirm, unlinked, empty- Toolbar compile of
toolbar.buttonslist with boot errors; scaffold stub emits the list syntax - OpenAPI converter union rewrite for
cabana.jsonScalarandcabana.fieldContext - fonoteka:
AdminFieldRelations()on albums (genre, artists) and collections (owner);messagesandtoolbar.buttonsin all five configs; new lang keys; testsTestPhase10AlbumRelations,TestPhase10CollectionOwnerReadOnly,TestPhase10ControllerCopy,TestPhase10Controllers - cabana tests
TestPhase10RelationOptions,TestPhase10RelationSave,TestPhase10RelationForgedID,TestPhase10RelationBoot,TestPhase10Messages,TestPhase10Toolbar,TestPhase10Bundle,TestPhase10FilterOptions,TestPhase10OpenAPIConformance; phrasebookTestPhase10Forms,TestPhase10LangOverride,TestPhase10SPAKeysResolve
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.
(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> |
<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>