85 KiB
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 builtdist/is embedded throughembed.FSin 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 intoindex.htmlat 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/v1from 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). Theapisegment 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 ascheck-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 (
FieldRenderermapstypeto component). Any unregistered type renders the design'sUnsupportedFieldbox ("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.jsis 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 insidedist/, 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.goswitches 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.yamlandconfig_form.yamlaccept an optionalmessages:block of phrasebook keys (e.g.recordCount,searchPrompt,deleteConfirm,emptyon lists;create,savedon 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 withIntl.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"). Unknownmessageskeys fail at boot (Phase 9DisallowUnknownFieldrule). - D-14: Toolbar is declarative:
toolbar.buttons: [create, delete]lists built-in actions in display order (creategated by create permission;deleteis bulk delete, enabled with a selection, confirming withdeleteConfirm), plustoolbar.search. A string value (Winter'sbuttons: list_toolbarpartial) 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
cabanahandlers, generated insummercms.go, converted to OpenAPI 3 (reuse theswagger2openapi.goapproach), committed, and fed to openapi-typescript, with a drift check. The framework SPA never reads an app repo's document.fonoteka.go/docs/openapi.jsonremains 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 fromnameFrom, on the Phase 9 D-11 list contract. An optionalRelationExtendOptionsQuery(ctx, field, *gorm.DB) *gorm.DBhook on the admin controller lets apps scope options (e.g. to the active collection). Permission gating is the controller's. TheemptyOptionstays 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]}.cabanamaps belongs-to to the foreign key and syncs many-to-many through the Phase 5 join-table contract inside the save transaction, afterFormBeforeCreate/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). Thebackendguard accepts the cookie and still acceptsAuthorization: Bearerfor 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
HasSettingspages 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.yamlscopes of the three Phase 9 shapes (switch, daterange, model-backed) and drivesfilter[<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
cabanachange. 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 servefor development. - Exact
messageskey 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
recordsPerPageand the Phase 9 cap). - Unsaved-changes confirmation, create-then-redirect behaviour (use
config_formcreate.redirect/redirectClosesemantics mapped to SPA routes), toast queue. - Whether admin paths are removed from
fonoteka.go/docs/openapi.jsononce the framework document exists (recommended: yes, one owner per path). - Package layout inside
summercms.gofor the embed/serve package, following beach-themed naming.
Deferred Ideas (OUT OF SCOPE)
- Phase 10.1: runtime admin extension point.
AdminAssets()on controllers (WinteraddJs/addCss), served under the admin prefix and loaded when the controller opens;type: widgetrendered as custom elements (plugins ship plain JS, no Vue coupling);type: partialrendered 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/httpServeMux,html/template,encoding/json. No new Go dependency unless research/decision names it. This phase needs zero new Go module dependencies (swag runs viago run ...@v1.16.6, not arequire). go vet ./...andgo 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.gois framework-only and must contain no Płytarium names — this includes SPA source, SPA test fixtures and the framework OpenAPI doc.fonoteka.gocarries the YAML/registry/lang/icon updates. Planning docs stay insummercms.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)
- No
backend::namespace exists.phrasebook.Activateloads onlycat.Load("lagoon", systemLangFS)and then eachpact.HasLangplugin [VERIFIED: phrasebook/translator.go:86-117];phrasebook/lang/contains onlyen/validate.yamlandpl/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 (GetInreturnskeywhenfindmisses [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]. - The fonoteka plugin has no lang files and no
LangFS(). Onlyplugins/golem15/user/lang/{en,pl}/lang.yamlexist and only the user plugin implementsLangFS[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. - 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. - Namespace overrides are impossible today:
Catalog.Loadfails with"duplicate namespace owner %q"when a namespace is loaded twice, andputfails 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. - Placeholder syntax mismatch: phrasebook interpolates Laravel
:name/:Name/:NAMEplaceholders [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. - Model-backed filter scopes have no choices source.
ListFilterfortype: scopecarriesModelClass(a PHP class string) andNameFrom, but nothing serves its options [VERIFIED: cabana/filter_schema.go:183-186, schema_types.go:24-35]. See Open Question 4. - Page-size mismatch: design says 12/24/48/96; Phase 9 only accepts
perPageOptionsorrecordsPerPage(fonoteka:[20]). The SPA must renderschema.perPageOptionsand hide the "Na stronę" select when it has one entry. recordUrlis Winter-shaped (golem15/fonoteka/albums/update/:id) andcreate.redirectlikewise [VERIFIED: fonoteka.go controllers//config_.yaml]; the SPA must map these onto D-10 routes (update/:id→/:id), not use them verbatim.- SPA reserved segments: controller IDs map to
/{vendor}/...; a vendor namedapi,loginorsettingswould 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)
Recommended Project Structure
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→ keybackend.uri(compass mapsconfig/<name>.yamlto<name>.*and envSUMMER_BACKEND__URI) [VERIFIED: compass/config.go section loading + existingSUMMER_ADMIN__JWT__SECRETconvention in cabana/auth.go:349]. - Normalize: trim, ensure leading
/, strip trailing/. Validate^(/[a-z0-9][a-z0-9_-]*)+$; reject/, and a first segment ofapi,oauth,.well-known,storage(or better: atBuildRoutertime, fail boot if any non-cabana route path starts with{prefix}/or equals{prefix}— generic and future-proof). - Default:
/backend(Winter'sbackendUridefault) [ASSUMED — A1]; fonoteka sets/plytadmininconfig/backend.yaml. - The prefix is computed once in
cabana.Activateand stored onservice(e.g.s.prefix);mountusess.api() = s.prefix + "/api/v1". A zeroservice{}in tests must default to the same value, sinceTestPhase09PermissionMatrixcalls(&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/v1literals.
Pattern 2: Embedded SPA serving (boardwalk)
What: path-agnostic build + one-time index rewrite + safe fallback.
- Vite:
base: command === 'build' ? './' : '/'. Relative base is the documented option "for scenarios where the base path is unknown in advance" and requiresimport.metasupport [CITED: vite.dev/guide/build]. JS chunks and CSSurl()s resolve relative to their own file, so onlyindex.htmlneeds rewriting. index.htmlsource carries<meta name="summer-admin-base" content="__SUMMER_ADMIN_BASE__">. AtHandler(prefix)construction, readdist/index.htmlonce, replace="./with="{prefix}/and the token withhtml.EscapeString(prefix), keep the bytes. Fail loudly (boot error) if the token is missing (catches a stale or hand-edited dist).- Do not use
<base href>(fragment links and SVGurl(#id)references resolve against it) and do not inject inline<script>(keeps a strictscript-src 'self'CSP possible). - Routes (raw group, no guard):
GET {prefix}andGET {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 answerapi/paths with the D-10 JSON 404 envelope, never with index.html. - Asset rules: clean the path with
path.Clean,fs.Statin 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. - 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(+ CSPframe-ancestors 'none'),X-Robots-Tag: noindex, nofollow(hidden admin URL). - Content types: set explicitly for
.woff2→font/woff2and.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. go.mod:ignore ./admin/node_modules— the go command then ignores it "when matching package patterns, such asallor./..." [CITED: go.dev/doc/go1.25, go.dev/ref/mod#go-mod-file-ignore].
Pattern 3: Cookie transport + CSRF (D-19)
- Guard: give
NewBackendJWTGuarda cookie name (add a variadiccookieNames ...stringor a new constructor).extractTokenalready prefersAuthorization: Bearerand falls back to cookies [VERIFIED: bouncer/jwt.go:244-263]. - Cookie: name
summer_admin(or__Secure-summer_admin;__Host-is impossible because it requiresPath=/),HttpOnly,Secure,SameSite=Strict,Path={prefix},Max-Age = admin.jwt.refresh_ttlminutes (refresh works on expired access tokens within the refresh window:refreshAudienceparsesWithoutClaimsValidation()and checksiat+refreshTTL[VERIFIED: bouncer/refresh.go:29-55]). - Transport selection rule (keeps "JS never reads the token" true):
POST /auth/loginwithX-Requested-With: XMLHttpRequest→ set cookie, body{"token_type":"cookie","expires_in":<seconds>}(noaccess_token).POST /auth/loginwithout 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 expiringSet-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: Bearerheader, requireX-Requested-With: XMLHttpRequestor return403 {"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 useapp.url + prefix + "/api/v1/auth/login". JWT verification does not checkiss[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]; withadmin.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 definitionmain.Envelope-array_main_Optionwithdata: {type: array, items: {$ref: main.Option}}. --requiredByDefaultmarks non-omitemptyfields required (main.Option required: ['label','value'],main.Plain required: ['data','meta']) and leavesomitemptyfields optional (main.Metahas norequired). 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$refto it. So the converter can rewrite the single componentcabana.jsonScalarto{"oneOf":[{"type":"string"},{"type":"number"},{"type":"boolean"}],"nullable":true}andcabana.fieldContextto{"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 yieldsallOfwithdata?: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. @Routerpaths are prefix-relative (/auth/login,/navigation,/{vendor}/{plugin}/{controller}/fields/{field}/options), per D-03.- Response docs use
Envelope[T]/ListEnvelope[T](typedListMeta) /ErrorEnvelope. Types that already exist (FormView,ListSchema,RelationSchema,NavigationEntry,SettingsEntry,SettingsResult,RelationResultrows) are referenced directly; records aremap[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 withhttptestand decodes the body into its documented type withDisallowUnknownFields, so the doc cannot drift from the wire. - Move
TestPhase09ContractInventoryto readadmin/openapi/admin.jsonwith relative route keys. Infonoteka.go, drop../summercms.go/cabanafrom--dirso admin paths leave the parity document (one owner per path); theBackendBearerdefinition ingenre_controller.gocan 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: relationfield must have a contract (fail loud, same style asrelation-manager requires AdminRelationContracts). Validate FK/pivot columns exist on the models (reusemodelColumns). Reject a belongs-toForeignKeythat is inprotectedFillKeyunless explicitly allowed (see Open Question 2). - Options query:
db.Model(NewRelated())→ applyRelationExtendOptionsQuery(ctx, field, q)if implemented →ILIKEsearch on the label column (reuseescapeLike) → 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 alsorequiredrelation fields, since the value is present) →FormBeforeCreate/Update→tx.Create/Save→ relation sync →FormAfterCreate/Update. D-18 says "afterFormBeforeCreate/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 beforetx.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 (...)throughRelationExtendOptionsQuery) 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 inmeta.labels({"genre":[{"value":3,"label":"Jazz"}],"artists":[...]}) — keepsdataround-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 servesdropdownfields and is not used bytype: 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 uniformlyRecord<string, string>and the SPA'st(msg, {count})picksnew Intl.PluralRules(locale).select(count)falling back toother. - Phrasebook needs one new read API, e.g.
(*Translator).Forms(locale, key) (map[string]string, bool)over the existingentry{text, plurals, pipes}[VERIFIED: phrasebook/loader.go:24-28]: text →{"other"}, plural map → copy, category-only pipes → map bypipePart.cat; exact/range pipes ({0},[2,*]) cannot map toIntl.PluralRules— reject them for keys served to the SPA (a test enumerates the bundle and fails on any unconvertible key). Frameworkbackend::langstrings should use YAML plural maps, not pipes. - Bundle endpoint: public
GET {prefix}/api/v1/lang(login screen needs strings before auth); locale fromAccept-LanguageviaschemaLocale, response{data: {"backend::lang.form.save": {"other":"Zapisz"}, ...}, meta: {locale}}for thebackend::lang.*prefix only (never dump other namespaces). AddCache-Control: no-cache. messagesYAML: a fixed struct per document (listMessages{RecordCount, SearchPrompt, Empty, EmptySearch, DeleteConfirm, Deleted, Create string},formMessages{Create, Update, Saved, DeleteConfirm, Deleted string}) decoded underDisallowUnknownField→ 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, resolvedmessagesobject. - Namespace home:
phrasebookcannot importcabana(cabana imports phrasebook) — load thebackendnamespace from an embed insidephrasebook(e.g.phrasebook/backendlang/lang/{en,pl}/lang.yaml, loaded ascat.Load("backend", ...)next tolagoon) 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 layoutlang/<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% ofexpires_in(from login/refresh bodies). - Active rail item = the navigation entry whose
controller(or anysideMenu[].controller) shares the route'svendor.pluginprefix. - 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.detailsisfield → 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 → componentmap (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-helporsquare). All design icons exist in@lucide/vue1.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 iconsor 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 committeddist/. - fonoteka.go icons (proposal): plugin
disc-3, albumsdisc-3, artistsmic-vocal, genrestags, stylespalette, collectionslibrary(if added), settingssearch.
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.redirectstrings 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 useacme.demo.*style fixtures, as cabana tests already do. v-htmlfor 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.
- 10-01 Tracer (both repos):
backend.uriconfig + prefix-mounted API (test helper sweep in both repos) · cookie + CSRF transport (guard, login/refresh/logout) ·boardwalkembed/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. Committeddist/. - 10-02 Backend contract growth (both repos):
backend::langnamespace (pl/en) + override layer +Forms()export +GET /lang·messages:(list/form) with defaults ·toolbar.buttonslist + string boot error · relation field contracts,fields/{field}/options+RelationExtendOptionsQueryinpact· 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 withsort_order) and collections (owner per Open Question 2), Collections nav item (per Open Question 1). Go tests per change. - 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.
- 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/. - 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
- 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, permissiongolem15.fonoteka.access_collections) as a deliberate, documented deviation — navigation is not part of the API parity contract. Resolved at discuss/plan time.
- Known: neither PHP nor Go navigation has one; the design shows "Kolekcje" (
- Collections
ownerrelation field under D-18.- Known:
owner_idis a protected fill key;FormBeforeCreateforces the owner to the admin's matched user; Winter let admins pick an owner (emptyOption: current_user). - Recommendation: keep
ownerread-only in Phase 10 (schema flagreadOnly/disabledfor relation fields whose FK is protected; shown as a label frommeta.labels); do not widenprotectedFillKey. Revisit only if the user wants owner reassignment.
- Known:
- Placeholder syntax
{count}vs:count.- Known: phrasebook and Winter lang files use
:name; D-13 writes{count}. - Recommendation: keep phrasebook's
:namesyntax in YAML (Winter strings port verbatim, server and SPA interpolate identically) and treat D-13's braces as notation; SPAinterpolate()mirrorsphrasebook.interpolate(:name,:Name,:NAME). Needs user confirmation because D-13 is locked.
- Known: phrasebook and Winter lang files use
- Choices for model-backed filter scopes (D-22).
- Known: nothing serves options for
type: scope;modelClassis a PHP class string with no Go model registry. - Recommendation: optional model capability
FilterOptions(scope string) []pact.Optionexposed viaGET .../filters/{scope}/options(or inlined into the list schema when small). Verified with fixtures only, since Płytarium has no filters.
- Known: nothing serves options for
- Where exactly the
backendlang namespace lives (phrasebook/backendlangembed 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 tasksnpx 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 --allgreen before/gsd-verify-work.
Wave 0 Gaps
admin/package.jsonscripts:dev,build(vue-tsc --noEmit && vite build),typecheck,test(vitest run),gen:apiadmin/vitest.config.ts(environment: 'happy-dom',include: ['tests/**/*.test.ts']) andadmin/tests/setup.ts(fetch mock for openapi-fetch viacreateClient({ fetch }))admin/tests/fixtures/— neutral schema fixtures covering tabs, all field types,colorpickerunsupported, filters of the three shapesboardwalk/boardwalk_test.goscripts/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.phpregisterNavigation,lang/pl/lang.php,models/Album.php; Wintermodules/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)
- swag README — composition, generics, swaggertype/extensions,
.swaggooverrides - openapi-fetch docs — createClient options, middleware
- Vite build guide — relative base
- Tailwind dark mode —
@custom-variant - Go 1.25 release notes and go.mod ignore directive
Tertiary (LOW confidence)
- Browser treatment of
Securecookies 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)