Files
summercms/.planning/phases/10-admin-vue-spa/10-RESEARCH.md
2026-09-27 13:23:46 +02:00

85 KiB
Raw Blame History

Phase 10: Admin Vue SPA - Research

Researched: 2026-09-27 Domain: Vue 3 + TypeScript admin SPA embedded in a Go binary; cabana admin API amendments (prefix, cookie auth, relation options/save, messages, toolbar, string bundle, framework-owned OpenAPI) Confidence: HIGH for the codebase baseline and the Go/OpenAPI pipeline (read or probed this session); MEDIUM for the JS stack choices (registry-verified, team-precedent-backed)

<user_constraints>

User Constraints (from CONTEXT.md)

Locked Decisions

Packaging and serving

  • D-01: The SPA source lives in summercms.go/admin (Vite project). Its built dist/ is embedded through embed.FS in a framework package and served by the binary. No separate deploy, no CORS.
  • D-02: The mount path is configurable (backend.uri, Winter-style), e.g. /plytadmin, /manage, /horoadmin, chosen per project so the admin location is not guessable. The committed build is path-agnostic: the Go server injects the configured base path into index.html at serve time. Client-side routes are handled by an SPA fallback under the prefix. — Reversibility: reversible
  • D-03: The admin API moves with the prefix: {backend.uri}/api/v1/... replaces the hardcoded /_admin/api/v1 from Phase 9 (D-09 there). This is safe because nothing outside the SPA calls the admin API yet (Nuxt and fonoteka-mcp never touch it). The api segment under the prefix is reserved and never used as an SPA route. OpenAPI paths are written relative (/navigation, /auth/login) and the SPA client gets its base URL at runtime. — Reversibility: costly — every admin route, test and the OpenAPI document are keyed to the prefix scheme.
  • D-04: The built dist/ is committed, with a check script that rebuilds and fails on drift (same pattern as check-openapi.sh). Rationale agreed with the user: everything an app plugin changes in the admin (YAML columns/fields, navigation, permissions, hooks, and in 10.1 partials and JS/CSS assets) is embedded in the plugin and needs only a Go binary rebuild; the Node build is needed only when the framework SPA itself changes (new built-in field type, layout). App developers never need Node.
  • D-05: Extension seam only: a field-type renderer registry (FieldRenderer maps type to component). Any unregistered type renders the design's UnsupportedField box ("Nieobsługiwany typ pola: <type>") instead of breaking the form. The full extension point is Phase 10.1 (see Deferred).

Look and components

  • D-06: The design reference is Direction C v2 from claude.ai/design, stored at .planning/phases/10-admin-vue-spa/design/ (README.md is the spec; Direction C v2.dc.html + support.js is the viewable reference). High fidelity: tokens, typography, radii, spacing, control heights, focus rings, copy, a11y roles, states (selected, empty, loading, 422, toast, modal), responsive rules (collapse below ~1100px, tablet width), light and dark mode. Build the generic components it lists (PluginRail, SectionPanel, SectionFlyout, AppShell, DataTable, ListToolbar, Pagination, FormTabs, FormGrid, FormField, FieldRenderer, RelationManager, RelationPickerModal, Toast, UserMenu).
  • D-07: Stack: Vue 3 + Vite + TypeScript + Tailwind + Reka UI primitives + lucide-vue-next icons (same family as vue-fonoteka-app). DM Sans and DM Mono are self-hosted inside dist/, not loaded from Google Fonts (works offline, no third-party requests from a hidden admin URL).
  • D-08: Real YAML only. Płytarium screens render exactly what their YAML declares (Albums: name, artists, format, genre, shelf; no tabs). The mock's extra fields, tabs and columns (Opis, Wypożyczony, Ulubiony, Utworzono, Szczegóły tab) are component fixtures for tests, not additions to Płytarium.
  • D-09: The SPA follows Phase 9's API contract where the mock differs: error envelope {"error": {code, message, details}} with 422 details as field → messages (not the mock's {errors}); list query and meta per Phase 9 D-11.
  • D-10: Routes derive from controller IDs: golem15.fonoteka.albums → {backend.uri}/golem15/fonoteka/albums, /create, /:id. The rail groups by plugin from /navigation; the active rail item comes from the route's owning plugin. Items the admin lacks permission for are absent (server-filtered), and a plugin whose side menu is empty after filtering is absent from the rail.
  • D-11: Navigation icons are lucide names in the registry. The SPA also carries a small map from Winter icon-* names to lucide so ported Winter plugins work unchanged; unknown names get a neutral fallback icon. fonoteka.go switches its navigation and settings icons to lucide names.
  • D-12: The Albums format column renders as plain text in Phase 10 (no icon pills).

Controller copy and toolbar

  • D-13: config_list.yaml and config_form.yaml accept an optional messages: block of phrasebook keys (e.g. recordCount, searchPrompt, deleteConfirm, empty on lists; create, saved on forms). Keys are resolved server-side in the admin's locale. Plural keys are served with all their CLDR forms ({"one": ..., "few": ..., "many": ..., "other": ...}) and the SPA picks the form with Intl.PluralRules(locale), because only the client knows the count (e.g. selected rows). Placeholders {count}, {name}, {term} are interpolated by the SPA. Every key is optional; the framework ships generic defaults (e.g. "Nowy rekord", "Usunąć zaznaczone ({count})?", "Zapisano"). Unknown messages keys fail at boot (Phase 9 DisallowUnknownField rule).
  • D-14: Toolbar is declarative: toolbar.buttons: [create, delete] lists built-in actions in display order (create gated by create permission; delete is bulk delete, enabled with a selection, confirming with deleteConfirm), plus toolbar.search. A string value (Winter's buttons: list_toolbar partial) is a boot error pointing at the new syntax. The five Płytarium config files are updated accordingly. Custom actions are Phase 10.1. — Reversibility: costly — changes the YAML contract every ported controller writes.

Types and relations

  • D-15: The SPA's types come from a framework-owned admin OpenAPI document: swag v1 annotations on cabana handlers, generated in summercms.go, converted to OpenAPI 3 (reuse the swagger2openapi.go approach), committed, and fed to openapi-typescript, with a drift check. The framework SPA never reads an app repo's document. fonoteka.go/docs/openapi.json remains the parity API document.
  • D-16: Records are typed generically: generated types cover envelopes, form/list/filter/relation/navigation/settings schemas, list meta and errors; a record is Record<string, unknown> read through its field schema. No per-controller TypeScript types and no hand-maintained duplicates of API shapes (success criterion 4).
  • D-17: Relation fields get their choices from a new endpoint, GET {prefix}/api/v1/{vendor}/{plugin}/{controller}/fields/{field}/options?search=&page=&per_page=, returning {value, label} with the label from nameFrom, on the Phase 9 D-11 list contract. An optional RelationExtendOptionsQuery(ctx, field, *gorm.DB) *gorm.DB hook on the admin controller lets apps scope options (e.g. to the active collection). Permission gating is the controller's. The emptyOption stays a schema property rendered by the SPA.
  • D-18: Form saves send relation values keyed by the YAML field name with ids: {"genre": 3, "artists": [4, 9]}. cabana maps belongs-to to the foreign key and syncs many-to-many through the Phase 5 join-table contract inside the save transaction, after FormBeforeCreate/FormBeforeUpdate. Record GET returns the same shape plus display labels so the form can show chips and the selected dropdown value without extra calls.

Session and UX

  • D-19: The admin JWT moves into an httpOnly, Secure, SameSite=Strict cookie scoped to {backend.uri}, set by login and refresh and cleared by logout. JS never reads the token (this matters once 10.1 lets plugin JS run in the admin origin). The backend guard accepts the cookie and still accepts Authorization: Bearer for CLI and tests. State-changing requests must carry a custom header (e.g. X-Requested-With) as CSRF defence in depth. It is still a JWT with the Phase 7 blacklist and sliding refresh; no session store. — Reversibility: costly — changes Phase 9's auth transport and the security matrix tests that pin it.
  • D-20: The SPA's own UI strings (Zapisz, Anuluj, Wyloguj, pagination, confirmations, defaults from D-13) live in the framework's phrasebook (backend::lang, pl and en) and are fetched as a resolved bundle for the admin's locale at startup. Projects can override or add locales without a Node rebuild.
  • D-21: The settings screen is in scope: the rail's "Ustawienia" lists HasSettings pages the admin may manage and renders each through the same form renderer against the Phase 9 settings endpoints (fonoteka: search_use_typesense).
  • D-22: The filter bar is in scope: it renders config_filter.yaml scopes of the three Phase 9 shapes (switch, daterange, model-backed) and drives filter[<scope>]. Płytarium has no filters, so it is verified with fixtures.
  • D-23: Testing follows the lean rules: Vitest component and unit tests as the phase's last plan; Go tests for every cabana change. Browser e2e (Playwright) is not part of Phase 10.

Claude's Discretion

  • SPA state management (Pinia or composables), router setup, HTTP client (openapi-fetch or a thin wrapper over generated types).
  • Dev loop: Vite dev server proxying the API vs rebuilding into the embedded path; recommended a Vite proxy to a running summer serve for development.
  • Exact messages key vocabulary beyond the examples, and the generic default wording.
  • Refresh timing (proactive before expiry plus one retry on 401, then redirect to login with the return URL).
  • Per-page options (design says 12/24/48/96; reconcile with recordsPerPage and the Phase 9 cap).
  • Unsaved-changes confirmation, create-then-redirect behaviour (use config_form create.redirect/redirectClose semantics mapped to SPA routes), toast queue.
  • Whether admin paths are removed from fonoteka.go/docs/openapi.json once the framework document exists (recommended: yes, one owner per path).
  • Package layout inside summercms.go for the embed/serve package, following beach-themed naming.

Deferred Ideas (OUT OF SCOPE)

  • Phase 10.1: runtime admin extension point. AdminAssets() on controllers (Winter addJs/addCss), served under the admin prefix and loaded when the controller opens; type: widget rendered as custom elements (plugins ship plain JS, no Vue coupling); type: partial rendered server-side via html/template; custom toolbar actions; a dev-mode switch serving plugin assets from disk. Needs a roadmap entry.
  • Ctrl+K command palette across all plugins' side menus.
  • Generic badge/icon column type (Albums format pills with disc/cassette icons).
  • Playwright browser e2e for the admin SPA (joins the Phase 8 carried-forward Playwright follow-up).
  • Admin navigation for the user and media plugins (shown in the mock) arrives with those ports. </user_constraints>

<phase_requirements>

Phase Requirements

ID Description Research Support
ADMIN-06 A minimal Vue 3 + TypeScript SPA renders login, permission-gated navigation, lists, forms and the relation manager for Albums, Artists, Collections, Genres and Styles using generated types Baseline of cabana (§Current State), prefix + serving (§Pattern 1–2), cookie auth (§Pattern 3), OpenAPI → TS pipeline probed end-to-end (§Pattern 4), relation options/save (§Pattern 5), messages/bundle (§Pattern 6), SPA structure (§Pattern 7–9), hidden prerequisites (§Gaps Found), Validation Architecture maps the four success criteria to tests
</phase_requirements>

Project Constraints (from CLAUDE.md)

  • Lean planning: few, large plans; present a plan-count checkpoint (one-line scope each) before writing PLAN.md files.
  • Unit tests are the last plan of the phase. Earlier plans may include smoke tests but are not blocked on coverage.
  • Go stdlib first: net/http ServeMux, html/template, encoding/json. No new Go dependency unless research/decision names it. This phase needs zero new Go module dependencies (swag runs via go run ...@v1.16.6, not a require).
  • go vet ./... and go test ./... green at every commit, in both repos.
  • Compiled plugins only; no runtime plugin loading (the SPA extension point is 10.1).
  • Two repositories: summercms.go is framework-only and must contain no Płytarium names — this includes SPA source, SPA test fixtures and the framework OpenAPI doc. fonoteka.go carries the YAML/registry/lang/icon updates. Planning docs stay in summercms.go/.planning.
  • Commits: no co-author tags; one logical change per commit; planning docs and code in separate commits.
  • Core plugin contracts (user, blog, pages, payment) must not break — the PHP originals are not touched.
  • GSD workflow: file edits happen through GSD commands.
  • Global user rule: never add co-author tags to commit messages.

Summary

Phase 9 left cabana as a complete, well-tested admin API, but hardcoded at /_admin/api/v1, Bearer-only, with untyped SuccessEnvelope{Data any} swag annotations that are consumed by the app repo's OpenAPI run and cross-read by a framework test (cabana/phase09_contract_test.go reads ../../fonoteka.go/docs/openapi.json). Relation form fields (type: relation) are schema-only today: scalarFormField excludes them and nestedValue drops arrays, so saves silently ignore genre/artists. Toolbar buttons are a Winter string (buttons: list_toolbar) compiled to ["create"]. There is no backend:: phrasebook namespace at all and — a bigger surprise — the fonoteka plugin ships no lang files, so every golem15.fonoteka::lang.* label the SPA would render currently comes back as the raw key. These gaps are prerequisites the planner must schedule, not polish.

The Go side is well served by what exists: surf compiles routes onto http.ServeMux with METHOD /path patterns, so the SPA fallback is two GET routes ({prefix} and {prefix}/{path...}) in a raw group; the bouncer JWT guard already supports cookie extraction (NewJWTGuard(..., cookieNames...)) — the backend constructor simply doesn't pass a cookie name. The OpenAPI pipeline was probed end-to-end this session: swag v1.16.6 handles Go generics (Envelope[[]Option]), --requiredByDefault marks non-omitempty fields required, opaque structs like jsonScalar become a single named definition that a converter can rewrite to a oneOf union, and openapi-typescript 7.13.0 produces a clean paths type for openapi-fetch.

On the JS side, pin the versions the team already runs in vue-fonoteka-app (Vue 3.5, Vite 7.3, Tailwind 4.3 via @tailwindcss/vite, Reka UI 2.9, Vitest 3.2 + happy-dom + @vue/test-utils, TypeScript 5.9 — openapi-typescript's peer is typescript ^5.x). lucide-vue-next is deprecated on npm in favour of @lucide/vue (same Lucide family, already in the team's app). Build with Vite base: './' and have Go rewrite index.html once at startup (asset URLs + a <meta> carrying the prefix) — no <base> element, no inline script.

Primary recommendation: Start with a tracer plan that delivers backend.uri + prefix-mounted API + cookie/CSRF auth + an embedded SPA (login → navigation shell → Genres list) + the framework OpenAPI→TS pipeline, then grow the backend contract (options, relation save, messages, toolbar, bundle, lang namespaces) before the remaining SPA screens; finish with the Vitest/Go coverage plan and a check-phase10.sh gate.

Architectural Responsibility Map

Capability Primary Tier Secondary Tier Rationale
Admin prefix (backend.uri) resolution + validation API / Backend (cabana, config) — Config is server state; routes and cookie Path derive from it
Serving dist/, index.html injection, SPA fallback Frontend Server (Go boardwalk package) CDN/Static (embedded FS) Same binary, same origin; injection is per-config, not per-request
Login, refresh, logout, cookie issuance, CSRF header check API / Backend (cabana + bouncer guard) Browser (sends header, never reads token) httpOnly cookie means the browser cannot hold auth logic
Permission filtering of navigation/settings API / Backend Browser (renders only what it gets) D-10: server-filtered, SPA never hides items itself
List/form/filter/relation schema compilation API / Backend (boot-time YAML compile) — Existing Phase 9 pipeline; SPA is a pure renderer
Relation options (search/paginate/scope) API / Backend Browser (debounced search UI) Scope hooks and permissions are server-side
Relation value mapping on save (FK + pivot sync) API / Backend (+ Database) — Must run inside the save transaction with hooks
Plural form selection for messages Browser (Intl.PluralRules) API (serves all CLDR forms) Only the client knows the count (D-13)
UI strings bundle API / Backend (phrasebook) Browser (cache for session) Overrides/locales without Node rebuild (D-20)
Routing, list state in URL, toasts, dirty-guard Browser / Client — Pure client concerns
TS types for API Build-time (swag → OpenAPI 3 → openapi-typescript) — No hand-written duplicates (SC-4)

Current State of cabana After Phase 9 (verified baseline)

Every row below was read this session. Quotes are verbatim.

Area Current code Phase 10 change
Route mount cabana/http.go:124-128: r.GroupRaw("/_admin/api/v1/auth", nil, func(g pact.Router) { / g.Post("/login", s.login, throttle) / g.Post("/refresh", s.refresh) / }) / r.GroupRaw("/_admin/api/v1", []string{"backend"}, func(g pact.Router) { [VERIFIED: cabana/http.go:122-166] Prefix from config; add /fields/{field}/options, /lang (bundle), SPA routes (D-03, D-17, D-20, D-02)
Issuer cabana/auth.go:405-414: return "/_admin/api/v1/auth/login" / return base + "/_admin/api/v1/auth/login" [VERIFIED: cabana/auth.go:405-414] Derive from prefix
Login body "access_token": token, "token_type": "bearer", [VERIFIED: cabana/auth.go:175-178] Cookie transport omits the token from the body (see Pattern 3)
Token extraction in handlers bearerToken reads only r.Header.Get("Authorization") with strings.CutPrefix(value, "Bearer ") [VERIFIED: cabana/auth.go:269-279] refresh/logout must read the cookie too
/auth/me profileOf returns "id", "login", "email", "first_name", "last_name", "is_superuser" and data["role"] = map[string]any{"id": ..., "code": ..., "name": ...} [VERIFIED: cabana/auth.go:250-266] Role name already present — UserMenu needs no backend change
Backend guard // NewBackendJWTGuard is bearer-only and requires AudienceBackend. / func NewBackendJWTGuard(secret string, users UserProvider, bl BlacklistStore, write func(http.ResponseWriter, error)) Guard [VERIFIED: bouncer/jwt.go:90-101] Pass a cookie name
Cookie support exists func NewJWTGuard(secret string, users UserProvider, bl BlacklistStore, cookieNames ...string) Guard and extractToken tries bearerToken(r) first, then each r.Cookie(name) [VERIFIED: bouncer/jwt.go:86-88, 244-263] Reuse; bearer still wins when present
Toolbar type listToolbar struct { Buttons string \yaml:"buttons"` ...}andcase "list_toolbar": return []string{"create"}, nil` [VERIFIED: cabana/list_schema.go:47-53, 273-283] buttons becomes a list [create, delete]; string is a boot error (D-14)
Form config doc formConfigDocument fields Name, Form, ModelClass, DefaultRedirect, Create *formRedirects, Update *formRedirects [VERIFIED: cabana/form_schema.go:40-52] Add Messages; also surface create.redirect/redirectClose to the SPA (discretion item)
Strict YAML yaml.NewDecoder(bytes.NewReader(raw), yaml.DisallowUnknownField()) [VERIFIED: cabana/schema.go:13-19] A fixed messages struct gets unknown-key rejection for free
Field types "text": {}, "textarea": {}, "number": {}, "checkbox": {}, "switch": {}, "dropdown": {}, "relation": {}, "relation-manager": {}, [VERIFIED: cabana/form_schema.go:23-26] SPA FieldRenderer registry covers exactly these + UnsupportedField fallback
Writable fields scalarFormField: case "text", "textarea", "number", "checkbox", "switch", "dropdown": return true [VERIFIED: cabana/crud.go:744-751]; nestedValue returns true for map[string]any, []any and those keys are dropped [VERIFIED: cabana/crud.go:765-772] Relation fields are currently ignored on save — D-18 is net-new code
Protected FKs protectedFillKey: "id", "created_at", "updated_at", "deleted_at", "owner_id", "user_id", "collection_id", "organisation_id", "organization_id", "scope_id", "role_id", "permissions", "is_superuser", "is_system", "is_activated", "password" [VERIFIED: cabana/crud.go:753-763] Collections owner → owner_id collides with this list (Open Question 2)
Record projection projectRecord emits id + cc.Writable fields only [VERIFIED: cabana/crud.go:695-720] Add relation values + labels (D-18)
Relation contract // Framework code never guesses a plugin pivot table or foreign key. type RelationContract struct { Name string; NewRelated func() any; NewPivot func() any; ParentForeignKey string; RelatedForeignKey string; Columns map[string]string; HookPivotColumns []string; ExcludedRelatedIDs func(parent any) ([]uint, error) } [VERIFIED: cabana/relation.go:20-35] Extend for form relation fields (belongs-to FK, pivot order column)
Navigation JSON NavigationEntry{Code, Label, Icon, Order, Controller, SideMenu} with JSON code,label,icon,order,controller,sideMenu [VERIFIED: cabana/navigation.go:13-20] Unchanged; SPA derives URL from controller
Option value type type Option struct { Value string \json:"value"`; Label string `json:"label"` }` [VERIFIED: pact/capabilities.go:183-186] Options endpoint should emit numeric ids (D-18 sends numbers) — use a cabana type, not pact.Option
Page sizes pageSizeAllowed: with no perPageOptions, only n == schema.RecordsPerPage is accepted [VERIFIED: cabana/query.go:180-194] Fonoteka lists declare recordsPerPage: 20 and no perPageOptions → SPA must render the schema's options, not 12/24/48/96
Route inventory test var phase09Routes = []struct { ... entries "POST /_admin/api/v1/auth/login" ... [VERIFIED: cabana/security_coverage_test.go:17-43] Rewrite to prefix-relative keys; add new routes
Cross-repo test read specPath := filepath.Clean(filepath.Join(filepath.Dir(file), "..", "..", "fonoteka.go", "docs", "openapi.json")) [VERIFIED: cabana/phase09_contract_test.go:19] Point at the framework doc (D-15)
OpenAPI generation go run github.com/swaggo/swag/cmd/swag@v1.16.6 init --dir plugins/golem15/fonoteka,../summercms.go/cabana --generalInfo controllers/genre_controller.go ... --parseDependency [VERIFIED: ../fonoteka.go/scripts/check-openapi.sh:11-16] Framework runs its own; fonoteka drops ../summercms.go/cabana
Router core mux.Handle(rt.method+" "+rt.path, h) inside handleRoute, which converts ServeMux conflict panics to errors [VERIFIED: surf/router.go:359-367] SPA fallback uses {path...} wildcard
Request locale towel.WithLocale(r.Context(), r.Header.Get("Accept-Language")) [VERIFIED: surf/router.go:665-668] The "admin's locale" is the browser's Accept-Language (backend users have no locale column)

Hardcoded prefix blast radius: grep -c "_admin/api/v1" finds ~92 occurrences in summercms.go (31 in admin_openapi.go, 23 in security_coverage_test.go, 22 in auth_test.go, rest in tests) and ~142 in fonoteka.go admin tests, plus docs/openapi.json [VERIFIED: grep this session]. No proxy/deploy config references /_admin [VERIFIED: grep of .conf/.yml/Caddyfile/Dockerfile found none].

Gaps Found (hidden prerequisites — plan these explicitly)

  1. No backend:: namespace exists. phrasebook.Activate loads only cat.Load("lagoon", systemLangFS) and then each pact.HasLang plugin [VERIFIED: phrasebook/translator.go:86-117]; phrasebook/lang/ contains only en/validate.yaml and pl/validate.yaml [VERIFIED: find]. Keys already used in YAML — backend::lang.list.no_records, backend::lang.list.search_prompt, backend::lang.form.none, backend::lang.list.column_created, backend::lang.form.update, backend::lang.list.delete_selected — resolve to the raw key today (GetIn returns key when find misses [VERIFIED: phrasebook/translator.go:134-146]). Winter's Polish source values exist at /media/nvme/dev/golem15/fonoteka/modules/backend/lang/pl/lang.php (e.g. 'no_records' => 'Brak rekordów w tym widoku.', 'search_prompt' => 'Szukaj...', 'save_and_close' => 'Zapisz i zamknij') [VERIFIED: grep].
  2. The fonoteka plugin has no lang files and no LangFS(). Only plugins/golem15/user/lang/{en,pl}/lang.yaml exist and only the user plugin implements LangFS [VERIFIED: find + grep in fonoteka.go]. The PHP source is /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/lang/{en,pl}/lang.php (148 lines each; e.g. 'menu_label' => 'Fonoteka', 'item' => ['menu_label' => 'Albumy', ...]) [VERIFIED: read]. Without porting it, the rail, columns, fields and titles render raw keys and success criteria 1–3 cannot pass visually.
  3. Collections has no navigation entry in either the Go port or the PHP original (sideMenu = albums, genres, styles, artists) [VERIFIED: fonoteka.go/.../admin_navigation.go; PHP Plugin.php:423-460]. The design mock shows "Kolekcje". See Open Question 1.
  4. Namespace overrides are impossible today: Catalog.Load fails with "duplicate namespace owner %q" when a namespace is loaded twice, and put fails on "duplicate key" [VERIFIED: phrasebook/loader.go:69-71, 215-229]. D-20's "projects can override or add locales" needs a new, explicit override layer.
  5. Placeholder syntax mismatch: phrasebook interpolates Laravel :name / :Name / :NAME placeholders [VERIFIED: phrasebook/translator.go:420-450] and Winter's backend strings use them ('create_title' => 'Nowy :name'); D-13 writes {count}, {name}, {term}. See Open Question 3.
  6. Model-backed filter scopes have no choices source. ListFilter for type: scope carries ModelClass (a PHP class string) and NameFrom, but nothing serves its options [VERIFIED: cabana/filter_schema.go:183-186, schema_types.go:24-35]. See Open Question 4.
  7. Page-size mismatch: design says 12/24/48/96; Phase 9 only accepts perPageOptions or recordsPerPage (fonoteka: [20]). The SPA must render schema.perPageOptions and hide the "Na stronę" select when it has one entry.
  8. recordUrl is Winter-shaped (golem15/fonoteka/albums/update/:id) and create.redirect likewise [VERIFIED: fonoteka.go controllers//config_.yaml]; the SPA must map these onto D-10 routes (update/:id → /:id), not use them verbatim.
  9. SPA reserved segments: controller IDs map to /{vendor}/...; a vendor named api, login or settings would collide with reserved SPA/API segments. Add a boot check in the registry.

Standard Stack

Go side (no new module dependencies)

Tool/Package Version Purpose Why
net/http ServeMux via surf Go 1.27.0 GET {prefix}/{path...} SPA fallback Already the router core [VERIFIED: surf/router.go:359-367]
embed (//go:embed all:dist) stdlib Ship dist/ in the binary D-01; all: needed for _-prefixed chunk names
io/fs, mime, net/http.ServeContent stdlib Serve assets with correct types/ETag-less caching stdlib-first
swag (tool, go run ...@v1.16.6) v1.16.6 Swagger 2.0 from cabana annotations Already the project tool; generics + --requiredByDefault verified this session
swagger2openapi.go (copied into summercms.go/scripts/) local Swagger 2 → OpenAPI 3.0.3 + scalar/context union rewrite Existing fonoteka converter [VERIFIED: ../fonoteka.go/scripts/swagger2openapi.go]

SPA (summercms.go/admin)

Library Version to pin Purpose Why this version
vue ^3.5.35 UI Team's installed 3.5.35 [VERIFIED: vue-fonoteka-app node_modules]; registry latest 3.5.43 [VERIFIED: npm view]
vite ^7.3.6 Build/dev server Team runs 7.3.5 [VERIFIED: .pnpm dir]; Vitest 3.2 peers vite ^5‖^6‖^7 — Vite 8 (latest 8.3.1) would force Vitest 4/5
@vitejs/plugin-vue ^6.0.8 SFC compile Peer vite: '^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0' [VERIFIED: npm view]
typescript ~5.9.3 Types openapi-typescript peer typescript: '^5.x' [VERIFIED: npm view]; do not take TS 7.0.2 (latest)
vue-tsc ^3.3.11 SFC typecheck in build Peer typescript: '>=5.0.0' [VERIFIED: npm view]
vue-router ^5.1.0 History routing with runtime base Team already on 5.1.0 via Nuxt [VERIFIED: .pnpm dir]; vite/pinia peers are optional [VERIFIED: npm view peerDependenciesMeta]
tailwindcss + @tailwindcss/vite ^4.3.0 Styling, CSS-first @theme tokens Team's 4.3.0; v4 dark variant via @custom-variant [CITED: tailwindcss.com/docs/dark-mode]
reka-ui ^2.9.10 Headless primitives (Dialog, DropdownMenu, Tabs, Switch, Checkbox, Combobox/Listbox, Tooltip) Team's 2.9.10
@lucide/vue ^1.17.0 Icons lucide-vue-next is deprecated: "Package deprecated. Please use @lucide/vue instead." [VERIFIED: npm view lucide-vue-next deprecated]; team already depends on @lucide/vue 1.17.0; sideEffects: false, named exports like Disc3, CircleAlert, Trash2 [VERIFIED: tarball]
openapi-fetch 0.17.0 Typed fetch client from paths Zero runtime deps, ~6 kB, createClient({baseUrl, credentials, headers}), client.use({onRequest,onResponse}) [CITED: openapi-ts.dev/openapi-fetch]
openapi-typescript (dev, via script) 7.13.0 paths types from OpenAPI 3 Same pin as fonoteka's pipeline; probed this session
@fontsource/dm-sans, @fontsource/dm-mono 5.3.0 Self-hosted fonts 400.css etc. include latin + latin-ext with unicode-range [VERIFIED: tarball]
vitest ^3.2.7 Unit/component tests Team's version; matches Vite 7
@vue/test-utils ^2.4.11 Component mounting Team's version
happy-dom ^20.11.6 DOM env Team's version and config precedent (environment: 'happy-dom') [VERIFIED: vue-fonoteka-app/vitest.config.ts]

No Pinia. Global state (auth user, navigation, strings bundle, toasts, sidebar collapsed) is five small module-level reactive/ref composables; this keeps the dependency list aligned with the stdlib-first ethos. (Claude's discretion.)

Alternatives Considered

Instead of Could Use Tradeoff
Vite 7 + Vitest 3 Vite 8.3 + Vitest 4.1 Newer (Rolldown); team has no Vite 8 experience; more churn for no phase benefit
@lucide/vue lucide-vue-next (as D-07 literally says) Deprecated, frozen at 1.0.0 (May 2026) [VERIFIED: npm view]; same icon family, so @lucide/vue honours D-07's intent — confirm with user (A2)
openapi-fetch thin hand wrapper over paths Re-implements path/query typing; openapi-fetch already does it
composables Pinia 3 Extra dep; devtools niceties only
npm + package-lock.json pnpm (team's Nuxt app) npm ships with Node and npx is already used by gate scripts; pnpm is fine if preferred (A5)

Installation (inside summercms.go/admin):

npm install vue@^3.5.35 vue-router@^5.1.0 reka-ui@^2.9.10 @lucide/vue@^1.17.0 openapi-fetch@0.17.0 @fontsource/dm-sans@5.3.0 @fontsource/dm-mono@5.3.0
npm install -D vite@^7.3.6 @vitejs/plugin-vue@^6.0.8 typescript@~5.9.3 vue-tsc@^3.3.11 tailwindcss@^4.3.0 @tailwindcss/vite@^4.3.0 vitest@^3.2.7 @vue/test-utils@^2.4.11 happy-dom@^20.11.6 openapi-typescript@7.13.0

Package Legitimacy Audit

gsd-tools query package-legitimacy check --ecosystem npm was run this session. "SUS: too-new" means the latest published version is days old; every flagged package is a multi-million-download project with an official repo and no postinstall. Pinning the team's already-installed (older) versions above sidesteps the recency flag.

Package Registry Downloads/wk Source Repo Verdict Disposition
vue npm 18.6M github.com/vuejs/core SUS (too-new latest) Approved at pinned ^3.5.35 (team-installed)
vite npm 206M github.com/vitejs/vite SUS (too-new latest) Approved at ^7.3.6
@vitejs/plugin-vue npm 10.4M github.com/vitejs/vite-plugin-vue SUS (too-new latest) Approved at ^6.0.8
vue-router npm 9.5M github.com/vuejs/router SUS (too-new latest) Approved at ^5.1.0 (team-installed)
typescript npm 328M github.com/microsoft/TypeScript OK Approved (~5.9.3)
vue-tsc npm 6.7M github.com/vuejs/language-tools OK Approved
tailwindcss npm 147M github.com/tailwindlabs/tailwindcss OK Approved
@tailwindcss/vite npm 55M github.com/tailwindlabs/tailwindcss OK Approved
reka-ui npm 2.2M github.com/unovue/reka-ui SUS (too-new latest) Approved at ^2.9.10 (team-installed)
@lucide/vue npm 643K github.com/lucide-icons/lucide SUS (too-new latest) Approved at ^1.17.0 (team-installed)
openapi-typescript npm 8.4M (openapi-ts) OK Approved (7.13.0)
openapi-fetch npm 10.5M (openapi-ts) OK Approved (0.17.0)
vitest npm 118M github.com/vitest-dev/vitest SUS (too-new latest) Approved at ^3.2.7 (team-installed)
@vue/test-utils npm 5.5M github.com/vuejs/test-utils SUS (too-new latest) Approved at ^2.4.11 (team-installed)
happy-dom npm 20.3M github.com/capricorn86/happy-dom SUS (too-new latest) Approved at ^20.11.6 (team-installed)
@fontsource/dm-sans npm 463K fontsource OK Approved
@fontsource/dm-mono npm 202K fontsource OK Approved
lucide-vue-next npm — lucide deprecated Not used (replaced by @lucide/vue)

Packages removed due to [SLOP]: none. Packages flagged [SUS]: the "too-new" ones above — all are pinned to versions already in the team's lockfile, so the planner may treat the pin itself as the verification; if a plan takes a newer version, add a checkpoint:human-verify before install. Postinstall scripts: none reported (postinstall: null for every package) [VERIFIED: legitimacy seam].

Architecture Patterns

System Architecture Diagram

Browser (admin at https://host/{prefix}/...)
   │
   │ GET {prefix}/golem15/fonoteka/albums            GET/POST {prefix}/api/v1/...  (cookie + X-Requested-With)
   ▼                                                   ▼
surf ServeMux ──────────────────────────────────────────────────────────────────────────────┐
   │ "GET {prefix}" / "GET {prefix}/{path...}"          │ "METHOD {prefix}/api/v1/…" (more specific wins)
   ▼                                                    ▼
boardwalk.Handler                                 cabana admin group
   ├─ path starts "api/" → JSON 404 envelope          ├─ csrf middleware (unsafe method + no Bearer ⇒ require header)
   ├─ file exists in dist → ServeContent              ├─ public: /auth/login, /auth/refresh, /lang
   │    (immutable cache for assets/)                 ├─ "backend" guard: Bearer, else cookie → Principal
   ├─ has file extension → 404                        └─ handlers → protect(controller perms)
   └─ else → index.html (rewritten once at boot:          ├─ schema/list/form/relation/settings/navigation
        ./assets → {prefix}/assets, meta prefix)          ├─ fields/{field}/options  → RelationExtendOptionsQuery
                                                          └─ create/update → fill scalars → validate → FormBefore*
                                                               → save row → relation sync (FK/pivot) → FormAfter*
SPA runtime:                                                          │
 main.ts reads <meta name="summer-admin-base"> ──► router base + openapi-fetch baseUrl ({prefix}/api/v1)
 boot: GET /lang (strings) → GET /auth/me (401 ⇒ /login) → GET /navigation → route view
 views: ListView(schema/list + list) · FormView(schema/form + show + options) · RelationManager · SettingsView
                                                                      │
                                                               Postgres (GORM)
summercms.go/
├── admin/                         # Vite project (D-01) — node_modules ignored by go.mod
│   ├── index.html                 # contains <meta name="summer-admin-base" content="__SUMMER_ADMIN_BASE__">
│   ├── package.json / package-lock.json
│   ├── vite.config.ts             # base './' on build; outDir ../boardwalk/dist; dev proxy
│   ├── vitest.config.ts
│   ├── openapi/admin.json         # committed framework OpenAPI 3 doc (D-15)
│   ├── src/
│   │   ├── api/schema.d.ts        # generated by openapi-typescript (committed, drift-checked)
│   │   ├── api/client.ts          # openapi-fetch client + CSRF header + 401/refresh middleware
│   │   ├── app/                   # runtime config (meta), router, i18n (bundle + plural), icons map
│   │   ├── state/                 # useAuth, useNavigation, useStrings, useToasts, useSidebar
│   │   ├── components/shell/      # AppShell, PluginRail, SectionPanel, SectionFlyout, UserMenu, Breadcrumbs
│   │   ├── components/list/       # DataTable, ListToolbar, FilterBar, Pagination, cells/
│   │   ├── components/form/       # FormTabs, FormGrid, FormField, FieldRenderer + fields/*, UnsupportedField
│   │   ├── components/relation/   # RelationManager, RelationPickerModal
│   │   ├── components/ui/         # Button, Input, Toast, ConfirmDialog (Reka-wrapped)
│   │   ├── views/                 # LoginView, ListView, FormView, SettingsIndexView, SettingsFormView, NotFound
│   │   └── styles/                # tokens.css (@theme + light/dark vars), fonts.css
│   └── tests/                     # *.test.ts + fixtures (generic "acme.demo" names — no Płytarium)
├── boardwalk/                     # beach-named embed/serve package
│   ├── boardwalk.go               # //go:embed all:dist ; Handler(prefix) http.Handler
│   ├── boardwalk_test.go
│   └── dist/                      # committed Vite output (D-04)
├── cabana/                        # API changes (prefix, cookie, options, relation save, messages, toolbar, lang)
│   └── lang/{en,pl}/lang.yaml     # backend:: namespace (or phrasebook/backendlang — see Pattern 6)
├── scripts/
│   ├── swagger2openapi.go         # copy of fonoteka converter + union rewrite
│   ├── check-admin-openapi.sh     # swag → convert → openapi-typescript → drift
│   ├── check-admin-dist.sh        # npm ci && npm run build → git diff --exit-code boardwalk/dist
│   └── check-phase10.sh           # phase gate (pattern of check-phase9.sh)
└── go.mod                         # + `ignore ./admin/node_modules`

Package name suggestion: boardwalk (where you walk along and look at the beach — the admin view). Any beach name works; it must be a directory the //go:embed pattern can reach without .., so Vite's build.outDir points into it.

Pattern 1: backend.uri configuration

  • Config file config/backend.yaml → key backend.uri (compass maps config/<name>.yaml to <name>.* and env SUMMER_BACKEND__URI) [VERIFIED: compass/config.go section loading + existing SUMMER_ADMIN__JWT__SECRET convention in cabana/auth.go:349].
  • Normalize: trim, ensure leading /, strip trailing /. Validate ^(/[a-z0-9][a-z0-9_-]*)+$; reject /, and a first segment of api, oauth, .well-known, storage (or better: at BuildRouter time, fail boot if any non-cabana route path starts with {prefix}/ or equals {prefix} — generic and future-proof).
  • Default: /backend (Winter's backendUri default) [ASSUMED — A1]; fonoteka sets /plytadmin in config/backend.yaml.
  • The prefix is computed once in cabana.Activate and stored on service (e.g. s.prefix); mount uses s.api() = s.prefix + "/api/v1". A zero service{} in tests must default to the same value, since TestPhase09PermissionMatrix calls (&service{}).mount(router) [VERIFIED: cabana/security_coverage_test.go:46-48].
  • Test churn: introduce one test helper per repo (adminAPI(path string) string) and mechanically replace "/_admin/api/v1 literals.

Pattern 2: Embedded SPA serving (boardwalk)

What: path-agnostic build + one-time index rewrite + safe fallback.

  1. Vite: base: command === 'build' ? './' : '/'. Relative base is the documented option "for scenarios where the base path is unknown in advance" and requires import.meta support [CITED: vite.dev/guide/build]. JS chunks and CSS url()s resolve relative to their own file, so only index.html needs rewriting.
  2. index.html source carries <meta name="summer-admin-base" content="__SUMMER_ADMIN_BASE__">. At Handler(prefix) construction, read dist/index.html once, replace ="./ with ="{prefix}/ and the token with html.EscapeString(prefix), keep the bytes. Fail loudly (boot error) if the token is missing (catches a stale or hand-edited dist).
  3. Do not use <base href> (fragment links and SVG url(#id) references resolve against it) and do not inject inline <script> (keeps a strict script-src 'self' CSP possible).
  4. Routes (raw group, no guard): GET {prefix} and GET {prefix}/{path...}. ServeMux picks the more specific API patterns first, but a GET to an unknown or POST-only API path falls through to the SPA wildcard — so the handler must answer api/ paths with the D-10 JSON 404 envelope, never with index.html.
  5. Asset rules: clean the path with path.Clean, fs.Stat in the embedded FS; directories are never listed; a missing path whose last segment has an extension (.js, .css, .woff2, .png, .map) is a 404; everything else gets index.html.
  6. Headers: index.html → Cache-Control: no-store; assets/* (hashed) → public, max-age=31536000, immutable; all → X-Content-Type-Options: nosniff, Referrer-Policy: same-origin, X-Frame-Options: DENY (+ CSP frame-ancestors 'none'), X-Robots-Tag: noindex, nofollow (hidden admin URL).
  7. Content types: set explicitly for .woff2 → font/woff2 and .woff → font/woff; Go's builtin table has neither [VERIFIED: $(go env GOROOT)/src/mime/type.go builtinTypesLower] and minimal containers may lack /etc/mime.types.
  8. go.mod: ignore ./admin/node_modules — the go command then ignores it "when matching package patterns, such as all or ./..." [CITED: go.dev/doc/go1.25, go.dev/ref/mod#go-mod-file-ignore].
  • Guard: give NewBackendJWTGuard a cookie name (add a variadic cookieNames ...string or a new constructor). extractToken already prefers Authorization: Bearer and falls back to cookies [VERIFIED: bouncer/jwt.go:244-263].
  • Cookie: name summer_admin (or __Secure-summer_admin; __Host- is impossible because it requires Path=/), HttpOnly, Secure, SameSite=Strict, Path={prefix}, Max-Age = admin.jwt.refresh_ttl minutes (refresh works on expired access tokens within the refresh window: refreshAudience parses WithoutClaimsValidation() and checks iat+refreshTTL [VERIFIED: bouncer/refresh.go:29-55]).
  • Transport selection rule (keeps "JS never reads the token" true):
    • POST /auth/login with X-Requested-With: XMLHttpRequest → set cookie, body {"token_type":"cookie","expires_in":<seconds>} (no access_token).
    • POST /auth/login without the header → today's Bearer body {"access_token","token_type":"bearer","expires_in"}, no cookie (CLI/tests).
    • POST /auth/refresh: token from Bearer → Bearer body; token from cookie → rotate cookie, body without token. A cookie-authenticated request never receives a token in a response body.
    • POST /auth/logout: read Bearer or cookie; blacklist jti (existing code); always send an expiring Set-Cookie (same name, same Path, Max-Age=-1).
  • CSRF middleware on the whole admin API (public auth routes included): for methods other than GET/HEAD/OPTIONS, if there is no Authorization: Bearer header, require X-Requested-With: XMLHttpRequest or return 403 {"error":{"code":"csrf_failed",...}}. With SameSite=Strict this is defence in depth; the non-safelisted header also forces a CORS preflight that the admin (no CORS) never answers.
  • Issuer (adminIssuer) must use app.url + prefix + "/api/v1/auth/login". JWT verification does not check iss [VERIFIED: grep bouncer/jwt.go], so old tokens stay valid until they expire.
  • Multi-tab refresh race: refresh blacklists the old jti from now + grace [VERIFIED: bouncer/refresh.go:74-86]; with admin.jwt.blacklist_grace: 0 (fonoteka's value) two tabs refreshing together log one out. Set a small grace (e.g. 30s) for cookie mode, single-flight refresh in the SPA, and on a failed refresh retry the original request once (the other tab may have rotated the cookie) before redirecting to login.

Pattern 4: Framework-owned admin OpenAPI → TypeScript (D-15/D-16) — probed this session

Probe results (scratch module, swag v1.16.6, openapi-typescript 7.13.0) [VERIFIED: local run, output pasted in summary form]:

  • Generics work: @Success 200 {object} Envelope[[]Option] generated definition main.Envelope-array_main_Option with data: {type: array, items: {$ref: main.Option}}.
  • --requiredByDefault marks non-omitempty fields required (main.Option required: ['label','value'], main.Plain required: ['data','meta']) and leaves omitempty fields optional (main.Meta has no required). Without it, openapi-typescript makes every property optional.
  • An opaque struct (type scalar struct{ raw json.RawMessage }) becomes one named definition {"type":"object"} and every use — field, pointer, map[string]scalar (additionalProperties.$ref) — is a $ref to it. So the converter can rewrite the single component cabana.jsonScalar to {"oneOf":[{"type":"string"},{"type":"number"},{"type":"boolean"}],"nullable":true} and cabana.fieldContext to {"oneOf":[{"type":"string"},{"type":"array","items":{"type":"string"}}]}.
  • extensions:"x-summer-scalar" struct tags do emit "x-summer-scalar": true (an alternative marker if a named-definition rewrite is ever not enough).
  • Composition Plain{data=[]Option} works but yields allOf with data?: optional in TS — prefer generics.

Recommended pipeline (summercms.go/scripts/check-admin-openapi.sh):

go run github.com/swaggo/swag/cmd/swag@v1.16.6 init \
  --dir cabana --generalInfo admin_openapi.go \
  --output "$TMP" --outputTypes json --requiredByDefault
go run scripts/swagger2openapi.go "$TMP/swagger.json" > admin/openapi/admin.json
npx --yes openapi-typescript@7.13.0 admin/openapi/admin.json -o admin/src/api/schema.d.ts
git diff --exit-code admin/openapi/admin.json admin/src/api/schema.d.ts   # CI / gate
  • General info lives at the top of cabana/admin_openapi.go (@title SummerCMS Admin API, @BasePath /, @securityDefinitions.apikey BackendBearer @in header @name Authorization). Swagger 2.0 cannot declare cookie auth; describe the cookie in the description.
  • @Router paths are prefix-relative (/auth/login, /navigation, /{vendor}/{plugin}/{controller}/fields/{field}/options), per D-03.
  • Response docs use Envelope[T] / ListEnvelope[T] (typed ListMeta) / ErrorEnvelope. Types that already exist (FormView, ListSchema, RelationSchema, NavigationEntry, SettingsEntry, SettingsResult, RelationResult rows) are referenced directly; records are map[string]any → Record<string, unknown>.
  • Conformance test (do not skip): doc types are separate from what handlers write (WriteData(w, 200, result.Data, map[string]any{...})). Add a Go test that calls each handler with httptest and decodes the body into its documented type with DisallowUnknownFields, so the doc cannot drift from the wire.
  • Move TestPhase09ContractInventory to read admin/openapi/admin.json with relative route keys. In fonoteka.go, drop ../summercms.go/cabana from --dir so admin paths leave the parity document (one owner per path); the BackendBearer definition in genre_controller.go can go with them if no fonoteka route references it.

Pattern 5: Relation fields — options endpoint (D-17) and save (D-18)

Extend the existing model-owned contract rather than parse GORM tags — relation.go states // Framework code never guesses a plugin pivot table or foreign key. [VERIFIED: cabana/relation.go:20-21]. Recommended shape (names illustrative):

// Form relation contract, declared by the controller alongside AdminRelationContracts().
type FieldRelationContract struct {
    Field       string          // fields.yaml key ("genre", "artists")
    Kind        string          // "belongsTo" | "belongsToMany"
    NewRelated  func() any
    ForeignKey  string          // belongsTo: owner column, e.g. "genre_id"
    NewPivot    func() any      // belongsToMany
    ParentForeignKey, RelatedForeignKey string
    OrderColumn string          // optional pivot column set to the array index (album_artists.sort_order)
    LabelColumn string          // physical column for nameFrom when it differs (owner.username → email)
}
  • Boot: every type: relation field must have a contract (fail loud, same style as relation-manager requires AdminRelationContracts). Validate FK/pivot columns exist on the models (reuse modelColumns). Reject a belongs-to ForeignKey that is in protectedFillKey unless explicitly allowed (see Open Question 2).
  • Options query: db.Model(NewRelated()) → apply RelationExtendOptionsQuery(ctx, field, q) if implemented → ILIKE search on the label column (reuse escapeLike) → order by label, id → paginate with the Phase 9 relation limits (per_page ≤ 100, default 20 [VERIFIED: cabana/relation.go normalizeRelationQuery]) → [{value: <id number>, label}] + ListMeta.
  • Save order inside the existing transaction: project scalars → lagoon.Fill → validate (now also required relation fields, since the value is present) → FormBeforeCreate/Update → tx.Create/Save → relation sync → FormAfterCreate/Update. D-18 says "after FormBeforeCreate/FormBeforeUpdate"; a belongs-to FK must be assigned before the row write, so set the FK field on the model after the Before hook but before tx.Save, and run the pivot sync after the row exists (needed for the new id on create).
  • Validate submitted ids against the same scoped options query (WHERE id IN (...) through RelationExtendOptionsQuery) inside the transaction; unknown/out-of-scope ids are a 422 on that field. Otherwise an admin can attach arbitrary rows (the options hook would only be cosmetic).
  • Pivot write follows the Phase 5 contract: "Writes go through an explicit delete-then-bulk-insert (or ON CONFLICT DO UPDATE) against the join table directly, in the same transaction as the parent save" [VERIFIED: lagoon/relations.go:12-19]. Never Association().Replace().
  • Record GET/POST/PUT responses: relation values in data ("genre": 3, "artists": [4, 9] in pivot order) plus labels in meta.labels ({"genre":[{"value":3,"label":"Jazz"}],"artists":[...]}) — keeps data round-trippable into the next PUT.
  • Album genre options today come from albumsAdminController.DropdownOptions("genre") with string values (strconv.FormatUint) [VERIFIED: fonoteka controllers/albums_admin_controller.go]; that path serves dropdown fields and is not used by type: relation. Don't reuse it for D-17.

Pattern 6: Messages (D-13) and the strings bundle (D-20)

  • Serve every message as a CLDR form map: plural entries → their map; plain text → {"other": text}. The TS type is then uniformly Record<string, string> and the SPA's t(msg, {count}) picks new Intl.PluralRules(locale).select(count) falling back to other.
  • Phrasebook needs one new read API, e.g. (*Translator).Forms(locale, key) (map[string]string, bool) over the existing entry{text, plurals, pipes} [VERIFIED: phrasebook/loader.go:24-28]: text → {"other"}, plural map → copy, category-only pipes → map by pipePart.cat; exact/range pipes ({0}, [2,*]) cannot map to Intl.PluralRules — reject them for keys served to the SPA (a test enumerates the bundle and fails on any unconvertible key). Framework backend::lang strings should use YAML plural maps, not pipes.
  • Bundle endpoint: public GET {prefix}/api/v1/lang (login screen needs strings before auth); locale from Accept-Language via schemaLocale, response {data: {"backend::lang.form.save": {"other":"Zapisz"}, ...}, meta: {locale}} for the backend::lang.* prefix only (never dump other namespaces). Add Cache-Control: no-cache.
  • messages YAML: a fixed struct per document (listMessages{RecordCount, SearchPrompt, Empty, EmptySearch, DeleteConfirm, Deleted, Create string}, formMessages{Create, Update, Saved, DeleteConfirm, Deleted string}) decoded under DisallowUnknownField → unknown keys fail at boot automatically. Values are phrase keys; validate at boot that each key exists in the catalog (fail loud on typos) — optional but cheap.
  • Defaults: when a key is omitted, the server fills the framework default key (backend::lang.list.record_count, etc.), so the SPA always receives a full, resolved messages object.
  • Namespace home: phrasebook cannot import cabana (cabana imports phrasebook) — load the backend namespace from an embed inside phrasebook (e.g. phrasebook/backendlang/lang/{en,pl}/lang.yaml, loaded as cat.Load("backend", ...) next to lagoon) or pass it in from surf. Keep it cycle-free.
  • Overrides/new locales (D-20): add an explicit override layer (e.g. optional pact.HasLangOverrides{ LangOverridesFS() fs.FS } with layout lang/<locale>/<namespace>/<group>.yaml, loaded after all namespaces, allowed to replace existing keys and add locales). Today's loader forbids both [VERIFIED: phrasebook/loader.go:69-71, 215-229].

Pattern 7: SPA client, auth flow and routing

// src/app/runtime.ts — read once at startup
const base = document.querySelector<HTMLMetaElement>('meta[name="summer-admin-base"]')?.content ?? '/backend'
export const runtime = { base, api: `${base}/api/v1` }

// src/api/client.ts
import createClient from 'openapi-fetch'
import type { paths } from './schema'
export const api = createClient<paths>({ baseUrl: runtime.api, credentials: 'same-origin' })
api.use({
  onRequest({ request }) { request.headers.set('X-Requested-With', 'XMLHttpRequest'); return request },
  async onResponse({ response, request }) { /* 401 → single-flight refresh → retry once → else router.push({name:'login', query:{redirect}}) */ return response },
})

// usage — path/query params are typed from the OpenAPI doc
const { data, error } = await api.GET('/{vendor}/{plugin}/{controller}', {
  params: { path: { vendor, plugin, controller }, query: { page, per_page, search, sort, dir } },
})
  • Router: createWebHistory(runtime.base); routes /login, /settings, /settings/:code, /:vendor/:plugin/:controller, /:vendor/:plugin/:controller/create, /:vendor/:plugin/:controller/:id(\\d+), catch-all NotFound. Guard: unauthenticated → /login?redirect=<fullPath>.
  • Boot order: strings bundle → /auth/me → /navigation → mount. Proactive refresh at ~80% of expires_in (from login/refresh bodies).
  • Active rail item = the navigation entry whose controller (or any sideMenu[].controller) shares the route's vendor.plugin prefix.
  • List state (search, sort, dir, page, per_page, filter[...]) lives in the URL query; search debounced 300 ms; selection resets on page/search change (design §Interactions).
  • 422 handling: error.error.details is field → string[] (Phase 9 D-10); map to fields, show banner, focus first invalid field, count badges on tabs.

Pattern 8: Field renderer registry (D-05)

// src/components/form/registry.ts
import type { Component } from 'vue'
const renderers = new Map<string, Component>([
  ['text', TextField], ['textarea', TextareaField], ['number', NumberField],
  ['checkbox', CheckboxField], ['switch', SwitchField], ['dropdown', DropdownField],
  ['relation', RelationField],          // single (belongsTo) vs multi decided by the field's relation kind in the schema
  ['relation-manager', RelationManager],
])
export const rendererFor = (type: string) => renderers.get(type) ?? UnsupportedField

The form schema should expose whether a relation field is single or multi (add e.g. "multiple": true to FormField JSON when the contract is belongsToMany) — the SPA cannot infer it from type: relation alone.

Pattern 9: Icons (D-11)

  • A curated name → component map (named imports from @lucide/vue, tree-shaken) covering the design's icon list plus a Winter map (icon-archive→archive, icon-circle→circle, icon-list-ul→list, icon-tags→tags, icon-user→user, icon-search→search, ...) and a neutral fallback (circle-help or square). All design icons exist in @lucide/vue 1.17.0 (sun, disc-3, disc, cassette-tape, mic-vocal, library, tags, settings, panel-left-close, panel-left-open, chevron-*, arrow-*, search, search-x, plus, trash-2, check, minus, x, circle-alert, user-plus, user-minus, log-out, puzzle, users, users-round, shield-check, image) [VERIFIED: tarball file list].
  • Do not import * as icons or template-string dynamic imports: the first pulls 1714 icons into the bundle [VERIFIED: 1714 icon modules in tarball], the second emits one chunk per icon into the committed dist/.
  • fonoteka.go icons (proposal): plugin disc-3, albums disc-3, artists mic-vocal, genres tags, styles palette, collections library (if added), settings search.

Anti-Patterns to Avoid

  • Serving index.html for {prefix}/api/... misses (masks API 404s/405s as HTML).
  • Returning the JWT in any response body for a cookie-authenticated request.
  • Relying on recordUrl/create.redirect strings verbatim as SPA routes.
  • Per-controller TS interfaces (violates D-16 / SC-4); read records through the field schema.
  • Using Płytarium names (Albumy, Enigmatic, plytarium) anywhere in summercms.go — tests use acme.demo.* style fixtures, as cabana tests already do.
  • v-html for labels/messages (plugin-supplied strings) — use text interpolation.

Don't Hand-Roll

Problem Don't Build Use Instead Why
Typed API client fetch wrappers + hand types openapi-fetch + generated paths SC-4; path/query/body typing for free
Swagger 2 → OAS3 new converter copy fonoteka.go/scripts/swagger2openapi.go + a small union rewrite Already proven with openapi-typescript 7.13.0
Plural selection a Polish rules table Intl.PluralRules(locale) in the SPA; phrasebook's go-i18n CLDR on the server Server and browser both implement CLDR; matching category names
Dialog focus trap, listbox, menus, tabs, switch custom a11y widgets Reka UI primitives Focus management, roles, keyboard handling
Cookie token extraction new guard extractToken cookie fallback in bouncer Existing, tested code path
Pivot sync GORM Association().Replace explicit delete + bulk insert per Phase 5 contract Association Mode cannot set business pivot columns [VERIFIED: lagoon/relations.go:12-19]
Fonts Google Fonts link / manual woff2 downloads @fontsource/dm-sans 400.css…700.css, @fontsource/dm-mono 400.css, 500.css Correct unicode-range split for latin/latin-ext (Polish diacritics)
MIME detection sniffing explicit map for fonts + mime.TypeByExtension Builtin table lacks woff/woff2

Key insight: the risky parts of this phase are contract seams (prefix, auth transport, relation writes, doc↔wire drift), not widgets. Spend verification effort there.

Runtime State Inventory (prefix move D-03 / transport change D-19)

Category Items Found Action Required
Stored data backend_jwt_blacklist rows keyed by jti — prefix-agnostic. JWT iss claim embeds /_admin/api/v1/auth/login, but iss is not verified [VERIFIED: grep bouncer/jwt.go] None (tokens expire naturally)
Live service config None — nothing outside the SPA calls the admin API (D-03); no reverse-proxy config references /_admin [VERIFIED: grep] None
OS-registered state None — verified no systemd/cron/Caddy/Docker files in either repo reference the path None
Secrets/env vars SUMMER_ADMIN__JWT__SECRET unchanged; new optional SUMMER_BACKEND__URI Document in fonoteka config/backend.yaml
Build artifacts fonoteka.go/docs/openapi.json lists /_admin/api/v1/* (19 admin paths + 1 app path) [VERIFIED: python count] Regenerate without cabana dir; framework doc replaces it

Common Pitfalls

Pitfall 1: go:embed silently drops _-prefixed Vite chunks

What goes wrong: Rollup names helper chunks like _plugin-vue_export-helper-<hash>.js; //go:embed dist excludes files starting with _ or .. How to avoid: //go:embed all:dist; a Go test walks the embedded FS and asserts every <script src>/<link href> in index.html exists. Warning signs: blank admin page, 404 on a JS chunk only in the binary.

Pitfall 2: go test ./... walks into admin/node_modules

What goes wrong: some npm packages ship .go files; ./... then tries to build them. How to avoid: ignore ./admin/node_modules in summercms.go/go.mod (Go ≥1.25) [CITED: go.dev/ref/mod].

Pitfall 3: SPA fallback swallows API errors

What goes wrong: GET {prefix}/api/v1/typo (or GET on a POST-only route) matches GET {prefix}/{path...} and returns 200 HTML; the SPA's JSON parse fails far from the cause. How to avoid: the fallback returns the JSON 404 envelope for any path under api/; a test asserts it.

Pitfall 4: buttons: list_toolbar produces an unhelpful YAML type error

What goes wrong: decoding a scalar into []string yields goccy's generic "cannot unmarshal" message, not D-14's "boot error pointing at the new syntax". How to avoid: a custom UnmarshalYAML(ast.Node) on the toolbar buttons type (the codebase already does this for fieldMap/scopeMap) that detects a string node and returns e.g. toolbar.buttons must be a list of actions (create, delete); Winter partial "list_toolbar" is not supported.

Pitfall 5: delete in the toolbar without checkboxes

How to avoid: boot error when toolbar.buttons contains delete but showCheckboxes is false; duplicates and unknown actions are boot errors too.

Pitfall 6: Relation values saved outside scope

What goes wrong: options are scoped by RelationExtendOptionsQuery, but save accepts any id. How to avoid: revalidate ids through the same scoped query inside the transaction (Pattern 5); security test with a forged id.

Pitfall 7: Every generated TS property optional

What goes wrong: without --requiredByDefault, openapi-typescript renders data?:, meta?: everywhere, forcing ! noise and hiding real optionals. How to avoid: --requiredByDefault (verified this session) and omitempty on genuinely optional fields.

Pitfall 8: OpenAPI doc says one thing, handler writes another

How to avoid: the decode-with-DisallowUnknownFields conformance test per endpoint (Pattern 4).

Pitfall 9: Font subset files without unicode-range

What goes wrong: latin-400.css + latin-ext-400.css each declare the same face without unicode-range; the later wins and basic Latin disappears. How to avoid: import the weight files (400.css contains both subsets with unicode-range) [VERIFIED: tarball]. Note Vite copies both .woff2 and .woff (4 files per weight) into dist/.

Pitfall 10: Token refresh storms / multi-tab logout

See Pattern 3 (single-flight, grace > 0, retry-once).

Pitfall 11: Test fixtures leak app names into the framework

How to avoid: SPA fixtures reproduce the design's shapes (tabs, switch, checkbox, unsupported colorpicker) with neutral names; Polish UI strings are fine (they are framework backend::lang), Płytarium domain words are not.

Pitfall 12: dist drift from non-reproducible builds

How to avoid: commit the lockfile, build with npm ci, pin Node via engines (team uses >=22.6; local Node is 22.23.2), run the drift check in the phase gate; never hand-edit boardwalk/dist.

Pitfall 13: Collections owner vs protected FK

owner_id is in protectedFillKey and FormBeforeCreate overwrites OwnerID with the admin's matched user [VERIFIED: fonoteka collections_admin_controller.go]. A naive D-18 belongs-to mapping would either silently drop the field or open a mass-assignment hole. Decide explicitly (Open Question 2).

Code Examples

Boardwalk handler skeleton (stdlib only)

// Source: pattern derived from surf/router.go ServeMux usage and stdlib io/fs.
package boardwalk

import (
	"bytes"
	"embed"
	"errors"
	"html"
	"io/fs"
	"net/http"
	"path"
	"strings"
)

//go:embed all:dist
var distFS embed.FS

const baseToken = "__SUMMER_ADMIN_BASE__"

func Handler(prefix string, notFoundAPI http.Handler) (http.Handler, error) {
	root, err := fs.Sub(distFS, "dist")
	if err != nil {
		return nil, err
	}
	raw, err := fs.ReadFile(root, "index.html")
	if err != nil || !bytes.Contains(raw, []byte(baseToken)) {
		return nil, errors.New("boardwalk: dist/index.html is missing or has no base token")
	}
	index := bytes.ReplaceAll(raw, []byte(`="./`), []byte(`="`+prefix+`/`))
	index = bytes.ReplaceAll(index, []byte(baseToken), []byte(html.EscapeString(prefix)))
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		rel := strings.TrimPrefix(strings.TrimPrefix(r.URL.Path, prefix), "/")
		if rel == "api" || strings.HasPrefix(rel, "api/") {
			notFoundAPI.ServeHTTP(w, r)
			return
		}
		// ... stat rel in root (path.Clean), serve file with explicit font types and immutable cache;
		// extension-bearing misses → 404; everything else → index bytes with no-store.
		_ = path.Ext
	}), nil
}

Tailwind v4 tokens with class-based dark mode

/* Source: tailwindcss.com/docs/dark-mode (custom variant) */
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
@theme {
  --font-sans: "DM Sans", system-ui, sans-serif;
  --font-mono: "DM Mono", ui-monospace, monospace;
  --color-bg: var(--c-bg);
  --color-surface: var(--c-surface);
  --color-primary: var(--c-primary);
}
:root { --c-bg:#f4f6f9; --c-surface:#ffffff; --c-primary:#22304d; --ring:rgba(252,196,40,.55); }
.dark { --c-bg:#111726; --c-surface:#182033; --c-primary:#fcd34d; --ring:rgba(252,211,77,.45); }

(Values from design README §Colours.)

swag annotation for a typed endpoint

// AdminFieldOptions documents the relation options route (D-17).
//
// @Summary Relation field options
// @Tags admin
// @Produce json
// @Security BackendBearer
// @Param vendor path string true "Vendor"
// @Param plugin path string true "Plugin"
// @Param controller path string true "Controller"
// @Param field path string true "Field"
// @Param search query string false "Search"
// @Param page query integer false "Page"
// @Param per_page query integer false "Per page"
// @Success 200 {object} ListEnvelope[[]RelationOption]
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Failure 422 {object} ErrorEnvelope
// @Router /{vendor}/{plugin}/{controller}/fields/{field}/options [get]
func AdminFieldOptions() {}

Vite config

// Source: vite.dev/guide/build (relative base)
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import tailwindcss from '@tailwindcss/vite'

const devPrefix = process.env.SUMMER_ADMIN_DEV_PREFIX ?? '/backend'
export default defineConfig(({ command }) => ({
  base: command === 'build' ? './' : '/',
  plugins: [vue(), tailwindcss(), {
    name: 'summer-admin-dev-base',
    apply: 'serve',
    transformIndexHtml: (html) => html.replace('__SUMMER_ADMIN_BASE__', devPrefix),
  }],
  build: { outDir: '../boardwalk/dist', emptyOutDir: true, sourcemap: false },
  server: { proxy: { [`${devPrefix}/api`]: 'http://localhost:8080' } },
}))

State of the Art

Old Approach Current Approach When Changed Impact
lucide-vue-next @lucide/vue Lucide v1 (lucide-vue-next frozen at 1.0.0, May 2026) [VERIFIED: npm view] Use @lucide/vue
tailwind.config.js + darkMode: 'class' CSS-first @theme + @custom-variant dark Tailwind v4 No JS config file
Swagger 2 only from swag swag v1 + converter; swag v2 still RC — Keep the converter
Directory hacks to hide node_modules from Go go.mod ignore directive Go 1.25 One line in go.mod

Deprecated/outdated: lucide-vue-next (deprecated); TypeScript 7 is out but openapi-typescript 7.13 peers ^5.x.

Suggested Plan Breakdown (for the plan-count checkpoint)

Lean mode, tracer first. Five plans are suggested; the user adjusts.

  1. 10-01 Tracer (both repos): backend.uri config + prefix-mounted API (test helper sweep in both repos) · cookie + CSRF transport (guard, login/refresh/logout) · boardwalk embed/serve with index rewrite and fallback · framework admin OpenAPI pipeline (general info, typed envelopes for the routes the tracer uses, converter + union rewrite, openapi-typescript, drift script, contract test moved) · admin/ scaffold (Vite/TS/Tailwind tokens/fonts/Reka/@lucide) with LoginView, AppShell + PluginRail + SectionPanel (server-filtered navigation), read-only Genres list · fonoteka: config/backend.yaml (/plytadmin), fonoteka lang port + LangFS, lucide icons, drop cabana from its OpenAPI run. Committed dist/.
  2. 10-02 Backend contract growth (both repos): backend::lang namespace (pl/en) + override layer + Forms() export + GET /lang · messages: (list/form) with defaults · toolbar.buttons list + string boot error · relation field contracts, fields/{field}/options + RelationExtendOptionsQuery in pact · D-18 save/sync/labels · model-backed filter options (if chosen) · full typed OpenAPI for every route · fonoteka YAML updates (toolbar, messages), relation contracts for albums (genre, artists with sort_order) and collections (owner per Open Question 2), Collections nav item (per Open Question 1). Go tests per change.
  3. 10-03 Lists, forms and settings (summercms.go SPA): DataTable (sort/select/states), ListToolbar, FilterBar, Pagination · FormTabs/FormGrid/FormField/FieldRenderer registry + all field types + UnsupportedField · relation single/multi fields using options · create/update/delete flows with 422, toasts, dirty guard, redirect mapping · Settings index + form. All five controllers work.
  4. 10-04 Relation manager and shell polish: RelationManager + RelationPickerModal (search, paginate 5/page, link/unlink, confirm) · SectionFlyout/collapse <1100px persisted · UserMenu/logout · breadcrumbs · dark mode (system preference) · Winter icon map. Rebuild dist/.
  5. 10-05 Unit tests + gate (last, per CLAUDE.md): Vitest suites for every component/composable, remaining Go coverage (boardwalk, cabana new paths, phrasebook forms/overrides, bouncer cookie), scripts/check-phase10.sh (vet/test both repos, Postgres stage, OpenAPI drift, dist drift, types check), VALIDATION evidence.

Plans 3 and 4 can merge into one if the user wants four plans.

Assumptions Log

# Claim Section Risk if Wrong
A1 Default backend.uri is /backend (Winter default) when unset Pattern 1 Low — only affects apps that don't set it; alternative /_admin would avoid all test churn but is guessable
A2 @lucide/vue satisfies D-07's "lucide-vue-next icons (same family)" Standard Stack Low — same icons/API family; user may insist on the deprecated package
A3 Browsers accept Secure cookies on http://localhost dev (Chrome/Firefox treat localhost as a secure context) Pattern 3 Medium — if not, dev login fails; fallback is a dev-only config flag to drop Secure
A4 Vite leaves <meta content="__SUMMER_ADMIN_BASE__"> untouched and emits ="./assets/... references in index.html under base: './' Pattern 2 Low — verify on the first build; the Go boot check fails loudly if not
A5 npm (package-lock.json) rather than pnpm for admin/ Standard Stack Low — either works; team's Nuxt app uses pnpm
A6 Dark mode follows prefers-color-scheme with no toggle (design has no toggle control) Plan 4 Low — UI-SPEC may add a toggle to UserMenu
A7 Static DM Sans weights (no opsz axis) are acceptable vs the design's "opsz 9..40" Stack Low — subtle optical-size difference
A8 Setting sort_order = array index on artist sync is acceptable (Winter's relation widget left new rows at 0) Pattern 5 Low — admin API is not a parity surface
A9 The "admin's locale" is the browser Accept-Language (backend users have no locale column) Pattern 6 Low
A10 A 30-second blacklist_grace for admin cookie refresh is acceptable security-wise Pattern 3 Low-Medium — window where a rotated token still works

Open Questions

  1. Add a Collections side-menu item in fonoteka.go?
    • Known: neither PHP nor Go navigation has one; the design shows "Kolekcje" (library); success criteria 2–3 need Collections reachable.
    • Recommendation: add it (collections, library, permission golem15.fonoteka.access_collections) as a deliberate, documented deviation — navigation is not part of the API parity contract. Resolved at discuss/plan time.
  2. Collections owner relation field under D-18.
    • Known: owner_id is a protected fill key; FormBeforeCreate forces the owner to the admin's matched user; Winter let admins pick an owner (emptyOption: current_user).
    • Recommendation: keep owner read-only in Phase 10 (schema flag readOnly/disabled for relation fields whose FK is protected; shown as a label from meta.labels); do not widen protectedFillKey. Revisit only if the user wants owner reassignment.
  3. Placeholder syntax {count} vs :count.
    • Known: phrasebook and Winter lang files use :name; D-13 writes {count}.
    • Recommendation: keep phrasebook's :name syntax in YAML (Winter strings port verbatim, server and SPA interpolate identically) and treat D-13's braces as notation; SPA interpolate() mirrors phrasebook.interpolate (:name, :Name, :NAME). Needs user confirmation because D-13 is locked.
  4. Choices for model-backed filter scopes (D-22).
    • Known: nothing serves options for type: scope; modelClass is a PHP class string with no Go model registry.
    • Recommendation: optional model capability FilterOptions(scope string) []pact.Option exposed via GET .../filters/{scope}/options (or inlined into the list schema when small). Verified with fixtures only, since Płytarium has no filters.
  5. Where exactly the backend lang namespace lives (phrasebook/backendlang embed vs a surf-passed FS). Recommendation: phrasebook embed (simplest, cycle-free).

Environment Availability

Dependency Required By Available Version Fallback
Go toolchain all Go work ✓ go1.27.0 —
Node.js SPA build/tests, openapi-typescript ✓ v22.23.2 —
npm / npx install, npx openapi-typescript ✓ 12.0.2 pnpm 11.3.0 ✓
swag v1.16.6 OpenAPI generation ✓ (module cache; run via go run) v1.16.6 —
Docker Testcontainers Postgres tests ✓ 29.7.2 —
python3 gate scripts (phase9_detect pattern) ✓ 3.14.7 —
Network (npm registry) npm ci, npx ✓ (used this session) — offline cache

Missing dependencies with no fallback: none.

Validation Architecture

Test Framework

Property Value
Framework (Go) Go 1.27 testing + testify; Testcontainers Postgres harness (existing)
Framework (SPA) Vitest ^3.2.7 + @vue/test-utils ^2.4.11 + happy-dom ^20.11.6
Config files none for Go; admin/vitest.config.ts (Wave 0)
Quick run (Go) go test ./cabana ./bouncer ./phrasebook ./boardwalk -count=1
Quick run (SPA) cd admin && npx vitest run --reporter=dot
Full suite go vet ./... && go test ./... && (cd ../fonoteka.go && go vet ./... && go test ./...) && (cd admin && npm run typecheck && npm test)
Phase gate scripts/check-phase10.sh --all (Wave 0; modelled on check-phase9.sh)

Phase Requirements → Test Map

Req ID Behavior Test Type Automated Command File Exists?
ADMIN-06 SC1 Login via cookie; CSRF header required; token never in body for cookie transport; logout clears cookie unit + assembled go test ./cabana -run '^TestPhase10(CookieAuth|CSRF|Prefix)' -count=1 and (cd ../fonoteka.go && go test ./plugins/golem15/fonoteka -run '^TestPhase10AdminAuth' -count=1) ❌ Wave 0
ADMIN-06 SC1 Navigation is server-filtered; rail hides empty plugins; active rail from route unit (Go) + component go test ./cabana -run '^TestPhase09PermissionMatrix$' -count=1; cd admin && npx vitest run tests/shell Go ✅ / SPA ❌
ADMIN-06 SC2 Five controllers: list schema, list, form schema, show, create/update via prefix assembled Postgres (cd ../fonoteka.go && go test ./plugins/golem15/fonoteka -run '^TestPhase10Controllers' -count=1) ❌ Wave 0
ADMIN-06 SC2 DataTable/Form render from schema fixtures (all field types, unsupported fallback, 422 mapping, empty/loading/selected) component cd admin && npx vitest run tests/list tests/form ❌ Wave 0
ADMIN-06 SC3 Collections editors: candidates/search/link/unlink via prefix assembled Postgres (cd ../fonoteka.go && go test ./plugins/golem15/fonoteka -run '^TestCollectionsAdmin' -count=1) (existing, prefix-updated) ✅ (update)
ADMIN-06 SC3 RelationManager + picker (search debounce, paging 5, Dodaj (N), focus trap/return) component cd admin && npx vitest run tests/relation ❌ Wave 0
ADMIN-06 SC4 OpenAPI doc regenerates identically; TS types regenerate identically; doc matches wire script + unit scripts/check-admin-openapi.sh and go test ./cabana -run '^TestPhase10OpenAPIConformance$' -count=1 ❌ Wave 0
ADMIN-06 SC4 No hand-maintained API types; SPA typechecks against generated paths typecheck + guard cd admin && npm run typecheck plus a gate grep that fetch( appears only in src/api/client.ts ❌ Wave 0
D-02/D-04 Embedded dist serves; fallback; api 404; font MIME; cache headers; dist up to date unit + script go test ./boardwalk -count=1; scripts/check-admin-dist.sh ❌ Wave 0
D-13/D-14/D-20 messages compile/unknown-key boot error; toolbar list + string error; bundle forms; override layer unit go test ./cabana ./phrasebook -run '^TestPhase10(Messages|Toolbar|Bundle|LangOverride)' -count=1 ❌ Wave 0
D-17/D-18 options search/paginate/scope hook/permissions; relation save FK + ordered pivot sync + labels; forged ids 422 unit + Postgres go test ./cabana -run '^TestPhase10Relation' -count=1; (cd ../fonoteka.go && go test ./plugins/golem15/fonoteka -run '^TestAlbumsAdminRelations' -count=1) ❌ Wave 0

Sampling Rate

  • Per task commit: narrowest Go package test + go vet ./... in the touched repo; for SPA tasks npx vitest run <dir> + npm run typecheck.
  • Per wave merge: go test ./... in both repos + cd admin && npm test && npm run build + dist/OpenAPI drift scripts.
  • Phase gate: scripts/check-phase10.sh --all green before /gsd-verify-work.

Wave 0 Gaps

  • admin/package.json scripts: dev, build (vue-tsc --noEmit && vite build), typecheck, test (vitest run), gen:api
  • admin/vitest.config.ts (environment: 'happy-dom', include: ['tests/**/*.test.ts']) and admin/tests/setup.ts (fetch mock for openapi-fetch via createClient({ fetch }))
  • admin/tests/fixtures/ — neutral schema fixtures covering tabs, all field types, colorpicker unsupported, filters of the three shapes
  • boardwalk/boardwalk_test.go
  • scripts/check-admin-openapi.sh, scripts/check-admin-dist.sh, scripts/check-phase10.sh
  • Go test helper adminAPI(path) in cabana tests and in fonoteka admin tests

Security Domain

Applicable ASVS Categories

ASVS Category Applies Standard Control
V2 Authentication yes Existing bcrypt + login throttle (throttle:5,1) + dummy-hash timing path [VERIFIED: cabana/auth.go:145-158]; unchanged
V3 Session Management yes JWT in HttpOnly; Secure; SameSite=Strict; Path={prefix} cookie; logout blacklists jti and expires cookie; sliding refresh with blacklist grace; token never in body for cookie transport
V4 Access Control yes Server-filtered navigation/settings; protect() per controller before schema/SQL; options endpoint through the same protect; relation ids revalidated through the scoped query
V5 Input Validation yes DisallowUnknownFields on relation mutations (existing); relation id normalization (normalizeIDs); strict YAML (DisallowUnknownField) for messages/toolbar
V6 Cryptography no new HS256 via golang-jwt (existing); no custom crypto
V13/V14 Config & HTTP headers yes nosniff, X-Frame-Options: DENY/frame-ancestors 'none', Referrer-Policy: same-origin, X-Robots-Tag: noindex, no-store on index.html, no inline scripts (CSP-friendly)

Known Threat Patterns for this stack

Pattern STRIDE Standard Mitigation
CSRF on cookie-authenticated admin API Tampering SameSite=Strict + required X-Requested-With on unsafe methods without Bearer; login CSRF covered by requiring the header for cookie login
Token theft by same-origin script (10.1 plugin JS) Info disclosure httpOnly cookie; no token in bodies for cookie transport
IDOR via relation ids on save Elevation Revalidate ids through RelationExtendOptionsQuery-scoped query in the tx
Mass assignment of scope FKs via relation mapping Tampering Honour protectedFillKey for belongs-to FKs (Open Question 2)
XSS through plugin labels/messages Tampering Vue text interpolation only; no v-html
Clickjacking the admin Tampering X-Frame-Options: DENY + CSP frame-ancestors 'none'
Admin URL discovery Info disclosure Configurable prefix (D-02), noindex, SPA fallback does not reveal API structure on HTML routes
Path traversal in static serving Info disclosure path.Clean + fs.Sub embedded FS (no disk access), no directory listing
Multi-tab refresh race → forced logout DoS (self) Grace window + single-flight refresh + retry-once

Sources

Primary (HIGH confidence)

  • Codebase reads this session: cabana/{http,auth,admin_openapi,schema_types,schema,form_schema,list_schema,filter_schema,relation,navigation,crud,contracts,registry,query,settings}.go, cabana/{security_coverage,phase09_contract}_test.go, bouncer/{jwt,refresh}.go, phrasebook/{loader,translator,lang}.go, surf/{router,cors}.go, pact/capabilities.go, compass/config.go, lagoon/relations.go, internal/build/stubs/artifacts.tmpl, scripts/check-phase9.sh
  • fonoteka.go: scripts/check-openapi.sh, scripts/swagger2openapi.go, docs/openapi.json, controller YAML (5), model fields/columns (5), admin.go, admin_navigation.go, admin_settings.go, admin_permissions.go, albums/collections admin controllers, models/{album,album_artist,collection,artist,genre}.go, config/{admin,app}.yaml
  • PHP originals: fonoteka Plugin.php registerNavigation, lang/pl/lang.php, models/Album.php; Winter modules/backend/lang/pl/lang.php
  • Local probes: swag v1.16.6 generics/composition/--requiredByDefault/opaque-struct refs; swagger2openapi + openapi-typescript 7.13.0 output; fontsource and @lucide/vue tarball contents; Go builtin MIME table
  • npm registry (npm view) versions/peers/deprecation; gsd package-legitimacy seam

Secondary (MEDIUM confidence)

Tertiary (LOW confidence)

  • Browser treatment of Secure cookies on localhost (A3) — from training knowledge

Metadata

Confidence breakdown:

  • Codebase baseline and required cabana changes: HIGH — every claim read this session with line ranges
  • OpenAPI → TS pipeline: HIGH — executed end-to-end in a scratch module
  • JS stack: MEDIUM-HIGH — registry-verified, pinned to the team's installed versions
  • SPA patterns (serving, cookie flow, relation save design): MEDIUM — standard practice fitted to this codebase; Open Questions 1–4 need user input

Research date: 2026-09-27 Valid until: 2026-10-27 (JS versions move fast; re-run npm view before pinning if planning slips)