docs(10): research admin Vue SPA phase
This commit is contained in:
791
.planning/phases/10-admin-vue-spa/10-RESEARCH.md
Normal file
791
.planning/phases/10-admin-vue-spa/10-RESEARCH.md
Normal file
@@ -0,0 +1,791 @@
|
||||
# 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
|
||||
|
||||
1. **Add a Collections side-menu item in fonoteka.go?**
|
||||
- Known: neither PHP nor Go navigation has one; the design shows "Kolekcje" (`library`); success criteria 2–3 need Collections reachable.
|
||||
- Recommendation: add it (`collections`, `library`, permission `golem15.fonoteka.access_collections`) as a deliberate, documented deviation — navigation is not part of the API parity contract. Resolved at discuss/plan time.
|
||||
2. **Collections `owner` relation field under D-18.**
|
||||
- Known: `owner_id` is a protected fill key; `FormBeforeCreate` forces the owner to the admin's matched user; Winter let admins pick an owner (`emptyOption: current_user`).
|
||||
- Recommendation: keep `owner` **read-only** in Phase 10 (schema flag `readOnly`/`disabled` for relation fields whose FK is protected; shown as a label from `meta.labels`); do not widen `protectedFillKey`. Revisit only if the user wants owner reassignment.
|
||||
3. **Placeholder syntax `{count}` vs `:count`.**
|
||||
- Known: phrasebook and Winter lang files use `:name`; D-13 writes `{count}`.
|
||||
- Recommendation: keep phrasebook's `:name` syntax in YAML (Winter strings port verbatim, server and SPA interpolate identically) and treat D-13's braces as notation; SPA `interpolate()` mirrors `phrasebook.interpolate` (`:name`, `:Name`, `:NAME`). Needs user confirmation because D-13 is locked.
|
||||
4. **Choices for model-backed filter scopes (D-22).**
|
||||
- Known: nothing serves options for `type: scope`; `modelClass` is a PHP class string with no Go model registry.
|
||||
- Recommendation: optional model capability `FilterOptions(scope string) []pact.Option` exposed via `GET .../filters/{scope}/options` (or inlined into the list schema when small). Verified with fixtures only, since Płytarium has no filters.
|
||||
5. **Where exactly the `backend` lang namespace lives** (`phrasebook/backendlang` embed vs a surf-passed FS). Recommendation: phrasebook embed (simplest, cycle-free).
|
||||
|
||||
## Environment Availability
|
||||
|
||||
| Dependency | Required By | Available | Version | Fallback |
|
||||
|---|---|---|---|---|
|
||||
| Go toolchain | all Go work | ✓ | go1.27.0 | — |
|
||||
| Node.js | SPA build/tests, openapi-typescript | ✓ | v22.23.2 | — |
|
||||
| npm / npx | install, `npx openapi-typescript` | ✓ | 12.0.2 | pnpm 11.3.0 ✓ |
|
||||
| swag v1.16.6 | OpenAPI generation | ✓ (module cache; run via `go run`) | v1.16.6 | — |
|
||||
| Docker | Testcontainers Postgres tests | ✓ | 29.7.2 | — |
|
||||
| python3 | gate scripts (`phase9_detect` pattern) | ✓ | 3.14.7 | — |
|
||||
| Network (npm registry) | `npm ci`, `npx` | ✓ (used this session) | — | offline cache |
|
||||
|
||||
**Missing dependencies with no fallback:** none.
|
||||
|
||||
## Validation Architecture
|
||||
|
||||
### Test Framework
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Framework (Go) | Go 1.27 `testing` + testify; Testcontainers Postgres harness (existing) |
|
||||
| Framework (SPA) | Vitest ^3.2.7 + @vue/test-utils ^2.4.11 + happy-dom ^20.11.6 |
|
||||
| Config files | none for Go; `admin/vitest.config.ts` (Wave 0) |
|
||||
| Quick run (Go) | `go test ./cabana ./bouncer ./phrasebook ./boardwalk -count=1` |
|
||||
| Quick run (SPA) | `cd admin && npx vitest run --reporter=dot` |
|
||||
| Full suite | `go vet ./... && go test ./... && (cd ../fonoteka.go && go vet ./... && go test ./...) && (cd admin && npm run typecheck && npm test)` |
|
||||
| Phase gate | `scripts/check-phase10.sh --all` (Wave 0; modelled on `check-phase9.sh`) |
|
||||
|
||||
### Phase Requirements → Test Map
|
||||
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|
||||
|--------|----------|-----------|-------------------|-------------|
|
||||
| ADMIN-06 SC1 | Login via cookie; CSRF header required; token never in body for cookie transport; logout clears cookie | unit + assembled | `go test ./cabana -run '^TestPhase10(CookieAuth\|CSRF\|Prefix)' -count=1` and `(cd ../fonoteka.go && go test ./plugins/golem15/fonoteka -run '^TestPhase10AdminAuth' -count=1)` | ❌ Wave 0 |
|
||||
| ADMIN-06 SC1 | Navigation is server-filtered; rail hides empty plugins; active rail from route | unit (Go) + component | `go test ./cabana -run '^TestPhase09PermissionMatrix$' -count=1`; `cd admin && npx vitest run tests/shell` | Go ✅ / SPA ❌ |
|
||||
| ADMIN-06 SC2 | Five controllers: list schema, list, form schema, show, create/update via prefix | assembled Postgres | `(cd ../fonoteka.go && go test ./plugins/golem15/fonoteka -run '^TestPhase10Controllers' -count=1)` | ❌ Wave 0 |
|
||||
| ADMIN-06 SC2 | DataTable/Form render from schema fixtures (all field types, unsupported fallback, 422 mapping, empty/loading/selected) | component | `cd admin && npx vitest run tests/list tests/form` | ❌ Wave 0 |
|
||||
| ADMIN-06 SC3 | Collections editors: candidates/search/link/unlink via prefix | assembled Postgres | `(cd ../fonoteka.go && go test ./plugins/golem15/fonoteka -run '^TestCollectionsAdmin' -count=1)` (existing, prefix-updated) | ✅ (update) |
|
||||
| ADMIN-06 SC3 | RelationManager + picker (search debounce, paging 5, Dodaj (N), focus trap/return) | component | `cd admin && npx vitest run tests/relation` | ❌ Wave 0 |
|
||||
| ADMIN-06 SC4 | OpenAPI doc regenerates identically; TS types regenerate identically; doc matches wire | script + unit | `scripts/check-admin-openapi.sh` and `go test ./cabana -run '^TestPhase10OpenAPIConformance$' -count=1` | ❌ Wave 0 |
|
||||
| ADMIN-06 SC4 | No hand-maintained API types; SPA typechecks against generated `paths` | typecheck + guard | `cd admin && npm run typecheck` plus a gate grep that `fetch(` appears only in `src/api/client.ts` | ❌ Wave 0 |
|
||||
| D-02/D-04 | Embedded dist serves; fallback; api 404; font MIME; cache headers; dist up to date | unit + script | `go test ./boardwalk -count=1`; `scripts/check-admin-dist.sh` | ❌ Wave 0 |
|
||||
| D-13/D-14/D-20 | messages compile/unknown-key boot error; toolbar list + string error; bundle forms; override layer | unit | `go test ./cabana ./phrasebook -run '^TestPhase10(Messages\|Toolbar\|Bundle\|LangOverride)' -count=1` | ❌ Wave 0 |
|
||||
| D-17/D-18 | options search/paginate/scope hook/permissions; relation save FK + ordered pivot sync + labels; forged ids 422 | unit + Postgres | `go test ./cabana -run '^TestPhase10Relation' -count=1`; `(cd ../fonoteka.go && go test ./plugins/golem15/fonoteka -run '^TestAlbumsAdminRelations' -count=1)` | ❌ Wave 0 |
|
||||
|
||||
### Sampling Rate
|
||||
- **Per task commit:** narrowest Go package test + `go vet ./...` in the touched repo; for SPA tasks `npx vitest run <dir>` + `npm run typecheck`.
|
||||
- **Per wave merge:** `go test ./...` in both repos + `cd admin && npm test && npm run build` + dist/OpenAPI drift scripts.
|
||||
- **Phase gate:** `scripts/check-phase10.sh --all` green before `/gsd-verify-work`.
|
||||
|
||||
### Wave 0 Gaps
|
||||
- [ ] `admin/package.json` scripts: `dev`, `build` (`vue-tsc --noEmit && vite build`), `typecheck`, `test` (`vitest run`), `gen:api`
|
||||
- [ ] `admin/vitest.config.ts` (`environment: 'happy-dom'`, `include: ['tests/**/*.test.ts']`) and `admin/tests/setup.ts` (fetch mock for openapi-fetch via `createClient({ fetch })`)
|
||||
- [ ] `admin/tests/fixtures/` — neutral schema fixtures covering tabs, all field types, `colorpicker` unsupported, filters of the three shapes
|
||||
- [ ] `boardwalk/boardwalk_test.go`
|
||||
- [ ] `scripts/check-admin-openapi.sh`, `scripts/check-admin-dist.sh`, `scripts/check-phase10.sh`
|
||||
- [ ] Go test helper `adminAPI(path)` in cabana tests and in fonoteka admin tests
|
||||
|
||||
## Security Domain
|
||||
|
||||
### Applicable ASVS Categories
|
||||
| ASVS Category | Applies | Standard Control |
|
||||
|---------------|---------|-----------------|
|
||||
| V2 Authentication | yes | Existing bcrypt + login throttle (`throttle:5,1`) + dummy-hash timing path [VERIFIED: cabana/auth.go:145-158]; unchanged |
|
||||
| V3 Session Management | yes | JWT in `HttpOnly; Secure; SameSite=Strict; Path={prefix}` cookie; logout blacklists jti and expires cookie; sliding refresh with blacklist grace; token never in body for cookie transport |
|
||||
| V4 Access Control | yes | Server-filtered navigation/settings; `protect()` per controller before schema/SQL; options endpoint through the same `protect`; relation ids revalidated through the scoped query |
|
||||
| V5 Input Validation | yes | `DisallowUnknownFields` on relation mutations (existing); relation id normalization (`normalizeIDs`); strict YAML (`DisallowUnknownField`) for `messages`/`toolbar` |
|
||||
| V6 Cryptography | no new | HS256 via golang-jwt (existing); no custom crypto |
|
||||
| V13/V14 Config & HTTP headers | yes | `nosniff`, `X-Frame-Options: DENY`/`frame-ancestors 'none'`, `Referrer-Policy: same-origin`, `X-Robots-Tag: noindex`, `no-store` on index.html, no inline scripts (CSP-friendly) |
|
||||
|
||||
### Known Threat Patterns for this stack
|
||||
| Pattern | STRIDE | Standard Mitigation |
|
||||
|---------|--------|---------------------|
|
||||
| CSRF on cookie-authenticated admin API | Tampering | SameSite=Strict + required `X-Requested-With` on unsafe methods without Bearer; login CSRF covered by requiring the header for cookie login |
|
||||
| Token theft by same-origin script (10.1 plugin JS) | Info disclosure | httpOnly cookie; no token in bodies for cookie transport |
|
||||
| IDOR via relation ids on save | Elevation | Revalidate ids through `RelationExtendOptionsQuery`-scoped query in the tx |
|
||||
| Mass assignment of scope FKs via relation mapping | Tampering | Honour `protectedFillKey` for belongs-to FKs (Open Question 2) |
|
||||
| XSS through plugin labels/messages | Tampering | Vue text interpolation only; no `v-html` |
|
||||
| Clickjacking the admin | Tampering | `X-Frame-Options: DENY` + CSP `frame-ancestors 'none'` |
|
||||
| Admin URL discovery | Info disclosure | Configurable prefix (D-02), `noindex`, SPA fallback does not reveal API structure on HTML routes |
|
||||
| Path traversal in static serving | Info disclosure | `path.Clean` + `fs.Sub` embedded FS (no disk access), no directory listing |
|
||||
| Multi-tab refresh race → forced logout | DoS (self) | Grace window + single-flight refresh + retry-once |
|
||||
|
||||
## Sources
|
||||
|
||||
### Primary (HIGH confidence)
|
||||
- Codebase reads this session: `cabana/{http,auth,admin_openapi,schema_types,schema,form_schema,list_schema,filter_schema,relation,navigation,crud,contracts,registry,query,settings}.go`, `cabana/{security_coverage,phase09_contract}_test.go`, `bouncer/{jwt,refresh}.go`, `phrasebook/{loader,translator,lang}.go`, `surf/{router,cors}.go`, `pact/capabilities.go`, `compass/config.go`, `lagoon/relations.go`, `internal/build/stubs/artifacts.tmpl`, `scripts/check-phase9.sh`
|
||||
- `fonoteka.go`: `scripts/check-openapi.sh`, `scripts/swagger2openapi.go`, `docs/openapi.json`, controller YAML (5), model fields/columns (5), `admin.go`, `admin_navigation.go`, `admin_settings.go`, `admin_permissions.go`, albums/collections admin controllers, `models/{album,album_artist,collection,artist,genre}.go`, `config/{admin,app}.yaml`
|
||||
- PHP originals: fonoteka `Plugin.php` registerNavigation, `lang/pl/lang.php`, `models/Album.php`; Winter `modules/backend/lang/pl/lang.php`
|
||||
- Local probes: swag v1.16.6 generics/composition/`--requiredByDefault`/opaque-struct refs; swagger2openapi + openapi-typescript 7.13.0 output; fontsource and @lucide/vue tarball contents; Go builtin MIME table
|
||||
- npm registry (`npm view`) versions/peers/deprecation; gsd package-legitimacy seam
|
||||
|
||||
### Secondary (MEDIUM confidence)
|
||||
- [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–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)
|
||||
88
.planning/phases/10-admin-vue-spa/10-VALIDATION.md
Normal file
88
.planning/phases/10-admin-vue-spa/10-VALIDATION.md
Normal file
@@ -0,0 +1,88 @@
|
||||
---
|
||||
phase: "10"
|
||||
slug: "admin-vue-spa"
|
||||
# status lifecycle: draft (seeded by plan-phase) → validated (set by validate-phase §6)
|
||||
# audit-milestone §5.5 distinguishes NOT-VALIDATED (draft) from PARTIAL (validated + nyquist_compliant: false) (#2117)
|
||||
status: draft
|
||||
nyquist_compliant: false
|
||||
wave_0_complete: false
|
||||
created: "2026-09-27"
|
||||
---
|
||||
|
||||
# Phase 10 — Validation Strategy
|
||||
|
||||
> Per-phase validation contract for feedback sampling during execution.
|
||||
|
||||
---
|
||||
|
||||
## Test Infrastructure
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| **Framework** | Go 1.27 `testing` + testify (Testcontainers Postgres harness); Vitest ^3.2 + @vue/test-utils + happy-dom for the SPA |
|
||||
| **Config file** | none for Go; `admin/vitest.config.ts` (Wave 0 installs) |
|
||||
| **Quick run command** | `go test ./cabana ./bouncer ./phrasebook ./boardwalk -count=1` / `(cd admin && npx vitest run --reporter=dot)` |
|
||||
| **Full suite command** | `go vet ./... && go test ./... && (cd ../fonoteka.go && go vet ./... && go test ./...) && (cd admin && npm run typecheck && npm test)` |
|
||||
| **Estimated runtime** | ~180 seconds (Postgres-backed suites dominate) |
|
||||
|
||||
---
|
||||
|
||||
## Sampling Rate
|
||||
|
||||
- **After every task commit:** narrowest Go package test + `go vet ./...` in the touched repo; for SPA tasks `npx vitest run <dir>` + `npm run typecheck`
|
||||
- **After every plan wave:** full suite in both repos + `cd admin && npm run build` + `scripts/check-admin-dist.sh` + `scripts/check-admin-openapi.sh`
|
||||
- **Before `/gsd-verify-work`:** `scripts/check-phase10.sh --all` must be green
|
||||
- **Max feedback latency:** 180 seconds
|
||||
|
||||
---
|
||||
|
||||
## Per-Task Verification Map
|
||||
|
||||
Filled by the planner from each PLAN.md `<verify>` block. Requirement → test map from RESEARCH.md:
|
||||
|
||||
| Behavior | Requirement | Test Type | Automated Command | File Exists | Status |
|
||||
|----------|-------------|-----------|-------------------|-------------|--------|
|
||||
| Cookie login, CSRF header, no token in body, logout clears cookie, prefix mount | ADMIN-06 SC1 | unit + assembled | `go test ./cabana -run '^TestPhase10(CookieAuth\|CSRF\|Prefix)' -count=1` | ❌ W0 | ⬜ pending |
|
||||
| Server-filtered navigation; rail hides empty plugins | ADMIN-06 SC1 | unit + component | `go test ./cabana -run '^TestPhase09PermissionMatrix$' -count=1`; `(cd admin && npx vitest run tests/shell)` | Go ✅ / SPA ❌ W0 | ⬜ pending |
|
||||
| Five controllers list/form via prefix | ADMIN-06 SC2 | assembled Postgres | `(cd ../fonoteka.go && go test ./plugins/golem15/fonoteka -run '^TestPhase10Controllers' -count=1)` | ❌ W0 | ⬜ pending |
|
||||
| DataTable/Form render from schema fixtures | ADMIN-06 SC2 | component | `(cd admin && npx vitest run tests/list tests/form)` | ❌ W0 | ⬜ pending |
|
||||
| Collections editors search/link/unlink | ADMIN-06 SC3 | assembled + component | `(cd ../fonoteka.go && go test ./plugins/golem15/fonoteka -run '^TestCollectionsAdmin' -count=1)`; `(cd admin && npx vitest run tests/relation)` | Go ✅ (update) / SPA ❌ W0 | ⬜ pending |
|
||||
| OpenAPI doc + TS types regenerate identically; doc matches wire | ADMIN-06 SC4 | script + unit | `scripts/check-admin-openapi.sh`; `go test ./cabana -run '^TestPhase10OpenAPIConformance$' -count=1` | ❌ W0 | ⬜ pending |
|
||||
| Embedded dist serving, fallback, api 404, MIME, dist drift | D-02/D-04 | unit + script | `go test ./boardwalk -count=1`; `scripts/check-admin-dist.sh` | ❌ W0 | ⬜ pending |
|
||||
| messages / toolbar / string bundle / lang override | D-13/D-14/D-20 | unit | `go test ./cabana ./phrasebook -run '^TestPhase10(Messages\|Toolbar\|Bundle\|LangOverride)' -count=1` | ❌ W0 | ⬜ pending |
|
||||
| Relation options + relation save + forged ids 422 | D-17/D-18 | unit + Postgres | `go test ./cabana -run '^TestPhase10Relation' -count=1` | ❌ W0 | ⬜ pending |
|
||||
|
||||
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
|
||||
|
||||
---
|
||||
|
||||
## Wave 0 Requirements
|
||||
|
||||
- [ ] `admin/package.json` scripts: `dev`, `build`, `typecheck`, `test`, `gen:api`
|
||||
- [ ] `admin/vitest.config.ts` + `admin/tests/setup.ts` (fetch mock for openapi-fetch)
|
||||
- [ ] `admin/tests/fixtures/` — neutral schema fixtures (tabs, all field types, unsupported type, three filter shapes)
|
||||
- [ ] `boardwalk/boardwalk_test.go`
|
||||
- [ ] `scripts/check-admin-openapi.sh`, `scripts/check-admin-dist.sh`, `scripts/check-phase10.sh`
|
||||
- [ ] Go test helper for prefix-relative admin API paths in cabana and fonoteka admin tests
|
||||
|
||||
---
|
||||
|
||||
## Manual-Only Verifications
|
||||
|
||||
| Behavior | Requirement | Why Manual | Test Instructions |
|
||||
|----------|-------------|------------|-------------------|
|
||||
| Visual fidelity to Direction C v2 (tokens, dark mode, collapse below ~1100px) | ADMIN-06 (D-06) | No browser e2e in Phase 10 (D-23) | Run `summer serve` for fonoteka, open `{backend.uri}`, compare screens against `design/Direction C v2.dc.html` in light and dark mode at desktop and tablet widths |
|
||||
| Full login → navigate → edit → relation link flow in a real browser | ADMIN-06 SC1–SC3 | Playwright deferred | Log in as a limited admin and a superuser; confirm rail items differ; edit an Album, link/unlink a Collections editor |
|
||||
|
||||
---
|
||||
|
||||
## Validation Sign-Off
|
||||
|
||||
- [ ] All tasks have `<automated>` verify or Wave 0 dependencies
|
||||
- [ ] Sampling continuity: no 3 consecutive tasks without automated verify
|
||||
- [ ] Wave 0 covers all MISSING references
|
||||
- [ ] No watch-mode flags
|
||||
- [ ] Feedback latency < 180s
|
||||
- [ ] `nyquist_compliant: true` set in frontmatter
|
||||
|
||||
**Approval:** pending
|
||||
Reference in New Issue
Block a user