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

799 lines
87 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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"\` ...}` and `case "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`):**
```bash
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` → 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].
### Pattern 3: Cookie transport + CSRF (D-19)
- 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`):
```bash
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):
```go
// 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
```ts
// 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)
```ts
// 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)
```go
// 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
```css
/* 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
```go
// 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
```ts
// 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 (RESOLVED)
All five questions were resolved at plan time (2026-09-27); the decisions are recorded in 10-CONTEXT.md and implemented by the plans named below.
1. **Add a Collections side-menu item in fonoteka.go?** — RESOLVED by D-25
- 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.
- **RESOLVED (D-25):** fonoteka.go adds the `collections` side-menu item (lucide `library`, permission `golem15.fonoteka.access_collections`) as a documented deviation from the PHP navigation (Plan 10-01).
2. **Collections `owner` relation field under D-18.** — RESOLVED by D-26
- 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.
- **RESOLVED (D-26):** `owner` is read-only in Phase 10 (relation fields whose foreign key is a protected fill key are served `readOnly` and rendered as a label from `meta.labels`); `protectedFillKey` and `FormBeforeCreate` are not widened (Plan 10-02 Task 1).
3. **Placeholder syntax `{count}` vs `:count`.** — RESOLVED by D-24
- 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.
- **RESOLVED (D-24, locked in 10-CONTEXT.md plan-time resolutions):** `messages` placeholders use phrasebook/Winter syntax `:count`, `:name`, `:term` (with `:Name`/`:NAME` casing variants); D-13's braces were notation only, and the SPA's interpolation mirrors `phrasebook.interpolate` (Plans 10-02 Task 2 and 10-03 Task 1).
4. **Choices for model-backed filter scopes (D-22).** — RESOLVED by D-27
- 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.
- **RESOLVED (D-27):** optional model capability `FilterOptions(scope string) []pact.Option` served at `GET {prefix}/api/v1/{vendor}/{plugin}/{controller}/filters/{scope}/options` (not inlined into the list schema), verified with fixtures only (Plan 10-02 Task 3).
5. **Where exactly the `backend` lang namespace lives** (`phrasebook/backendlang` embed vs a surf-passed FS). Recommendation: phrasebook embed (simplest, cycle-free). — RESOLVED
- **RESOLVED (phrasebook embed, as planned in 10-02):** the files live at `phrasebook/backend/lang/{en,pl}/lang.yaml`, embedded by `phrasebook/lang.go` and loaded in `Activate` as namespace `backend` right after `lagoon`; projects override or add locales through `pact.HasLangOverrides` without a Node rebuild (D-20; Plan 10-02 Task 2).
## 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)
- [swag README](https://github.com/swaggo/swag/blob/master/README.md) — composition, generics, swaggertype/extensions, `.swaggo` overrides
- [openapi-fetch docs](https://openapi-ts.dev/openapi-fetch/) — createClient options, middleware
- [Vite build guide](https://vite.dev/guide/build) — relative base
- [Tailwind dark mode](https://tailwindcss.com/docs/dark-mode) — `@custom-variant`
- [Go 1.25 release notes](https://go.dev/doc/go1.25) and [go.mod ignore directive](https://go.dev/ref/mod#go-mod-file-ignore)
### 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–5 resolved at plan time (D-24 to D-27, phrasebook embed)
**Research date:** 2026-09-27
**Valid until:** 2026-10-27 (JS versions move fast; re-run `npm view` before pinning if planning slips)