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

48 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, estimate, must_haves
phase plan type wave depends_on files_modified autonomous requirements estimate must_haves
10-admin-vue-spa 01 execute 1
go.mod
.gitignore
bouncer/jwt.go
surf/router.go
surf/admin_prefix_test.go
cabana/prefix.go
cabana/csrf.go
cabana/http.go
cabana/auth.go
cabana/registry.go
cabana/admin_openapi.go
cabana/admin_paths_test.go
cabana/phase10_auth_test.go
cabana/auth_test.go
cabana/security_coverage_test.go
cabana/security_test.go
cabana/crud_lifecycle_test.go
cabana/bulk_test.go
cabana/commands_test.go
cabana/phase09_contract_test.go
boardwalk/boardwalk.go
boardwalk/boardwalk_test.go
boardwalk/dist/**
internal/tools/swagger2openapi/main.go
scripts/check-admin-openapi.sh
scripts/check-admin-dist.sh
admin/package.json
admin/package-lock.json
admin/index.html
admin/vite.config.ts
admin/vitest.config.ts
admin/tsconfig.json
admin/env.d.ts
admin/openapi/admin.json
admin/src/main.ts
admin/src/App.vue
admin/src/app/runtime.ts
admin/src/app/router.ts
admin/src/app/i18n.ts
admin/src/app/icons.ts
admin/src/app/controllerRoutes.ts
admin/src/api/client.ts
admin/src/api/schema.d.ts
admin/src/api/types.ts
admin/src/state/useAuth.ts
admin/src/state/useNavigation.ts
admin/src/styles/main.css
admin/src/components/shell/AppShell.vue
admin/src/components/shell/PluginRail.vue
admin/src/components/shell/SectionPanel.vue
admin/src/components/list/DataTable.vue
admin/src/views/LoginView.vue
admin/src/views/ListView.vue
admin/src/views/NotFoundView.vue
admin/tests/setup.ts
admin/tests/fixtures/navigation.json
admin/tests/fixtures/widgets.list-schema.json
admin/tests/fixtures/widgets.list.json
admin/tests/smoke/tracer.smoke.test.ts
../fonoteka.go/config/backend.yaml
../fonoteka.go/config/admin.yaml
../fonoteka.go/scripts/check-openapi.sh
../fonoteka.go/docs/openapi.json
../fonoteka.go/plugins/golem15/fonoteka/controllers/genre_controller.go
../fonoteka.go/plugins/golem15/fonoteka/lang.go
../fonoteka.go/plugins/golem15/fonoteka/lang/en/lang.yaml
../fonoteka.go/plugins/golem15/fonoteka/lang/pl/lang.yaml
../fonoteka.go/plugins/golem15/fonoteka/admin_navigation.go
../fonoteka.go/plugins/golem15/fonoteka/admin_settings.go
../fonoteka.go/plugins/golem15/fonoteka/admin_paths_test.go
../fonoteka.go/plugins/golem15/fonoteka/admin_phase10_tracer_test.go
../fonoteka.go/plugins/golem15/fonoteka/admin_phase10_auth_test.go
../fonoteka.go/plugins/golem15/fonoteka/plugin_boot_test.go
../fonoteka.go/plugins/golem15/fonoteka/admin_tracer_test.go
../fonoteka.go/plugins/golem15/fonoteka/admin_auth_test.go
../fonoteka.go/plugins/golem15/fonoteka/admin_albums_test.go
../fonoteka.go/plugins/golem15/fonoteka/admin_artists_test.go
../fonoteka.go/plugins/golem15/fonoteka/admin_collections_test.go
../fonoteka.go/plugins/golem15/fonoteka/admin_genres_test.go
../fonoteka.go/plugins/golem15/fonoteka/admin_styles_test.go
../fonoteka.go/plugins/golem15/fonoteka/admin_metadata_test.go
../fonoteka.go/plugins/golem15/fonoteka/admin_settings_test.go
../fonoteka.go/plugins/golem15/fonoteka/admin_phase09_e2e_test.go
../fonoteka.go/plugins/golem15/fonoteka/admin_phase09_security_test.go
false
ADMIN-06
tokens raw_tokens tasks confidence
140000 140000 3 low
truths artifacts key_links prohibitions
Per D-01/D-02, the fonoteka binary serves the embedded SPA at its configured backend.uri (/plytadmin): GET /plytadmin and a deep link such as /plytadmin/golem15/fonoteka/genres both return index.html whose summer-admin-base meta and asset URLs carry /plytadmin, and every asset it references is served from the binary.
Per D-03, every admin API route answers only under {backend.uri}/api/v1; admin/openapi/admin.json lists prefix-relative paths (/auth/login, /navigation, /{vendor}/{plugin}/{controller}) and the SPA reads its API base from the served meta at runtime; a request to the former /_admin/api/v1 prefix reaches no admin handler.
Per D-19, an SPA login that carries X-Requested-With: XMLHttpRequest receives an HttpOnly, Secure, SameSite=Strict cookie scoped to the prefix and a body with token_type cookie and expires_in but no token; a login without that header keeps the Phase 9 Bearer body and sets no cookie.
Per D-19, a cookie-authenticated POST, PUT or DELETE to the admin API without X-Requested-With is refused with 403 and the D-10 code forbidden before any decoding, lookup or query; cookie refresh rotates the cookie; logout blacklists the jti and expires the cookie.
Per D-10/D-11/D-25, after login the SPA renders the server-filtered navigation grouped by plugin with lucide icons (Winter icon-* names mapped, unknown names shown with a neutral fallback), the fonoteka side menu includes Collections, and the rail omits a plugin whose side menu is empty after filtering.
Per D-15/D-16, every SPA API call goes through the openapi-fetch client typed by admin/src/api/schema.d.ts, generated by openapi-typescript from the committed framework document admin/openapi/admin.json; records are generic string-keyed maps read through their schema.
Per D-04, scripts/check-admin-dist.sh rebuilds the SPA from the committed lockfile and exits non-zero when the result differs from boardwalk/dist; scripts/check-admin-openapi.sh --check exits non-zero when the committed document or generated types differ from a fresh generation.
A GET to an unknown path under {backend.uri}/api/ returns the D-10 JSON not_found envelope, never index.html; a missing path whose last segment has a file extension is a 404, and a directory path is never listed.
statement verification
[flagged assumption A1] When backend.uri is unset the prefix is /backend (Winter's default); fonoteka sets /plytadmin in config/backend.yaml. backstop
statement verification
[flagged assumption A3] Browsers accept the Secure admin cookie on http://localhost during development; backend.cookie_secure: false is accepted only outside the production environment and fails activation in production. backstop
statement verification
[flagged assumption A10] A 30-second admin.jwt.blacklist_grace in fonoteka keeps two tabs from logging each other out on a concurrent cookie refresh; the SPA also single-flights refresh and replays once. backstop
path provides
cabana/prefix.go DefaultAdminPrefix and AdminPrefix(app) normalization and validation of backend.uri
path provides
cabana/csrf.go X-Requested-With requirement for cookie-authenticated unsafe admin requests
path provides
boardwalk/boardwalk.go Embedded dist serving with one-time index base injection, SPA fallback and api/ JSON 404 delegation
path provides
internal/tools/swagger2openapi/main.go Swagger 2 to OpenAPI 3.0 converter for the framework admin document
path provides
scripts/check-admin-openapi.sh Admin OpenAPI generation and --check drift gate
path provides
scripts/check-admin-dist.sh Committed dist drift gate
path provides
admin/src/api/client.ts Typed openapi-fetch client with runtime base URL, CSRF header and 401 handling
path provides
admin/src/views/ListView.vue Schema-driven read-only list rendering for any controller
path provides
../fonoteka.go/plugins/golem15/fonoteka/admin_phase10_tracer_test.go Assembled PostgreSQL proof of SPA serving, cookie login, navigation and Genres list
from to via pattern
surf/router.go cabana/http.go BuildRouter mounts cabana routes and rejects non-cabana routes under Routes.Prefix Prefix
from to via pattern
cabana/http.go boardwalk/boardwalk.go GET {prefix} and GET {prefix}/{path...} served by boardwalk.Handler with a D-10 not_found delegate boardwalk.Handler
from to via pattern
cabana/auth.go bouncer/jwt.go NewBackendJWTGuard receives the summer_admin cookie name; Bearer still wins AdminCookieName
from to via pattern
admin/src/api/client.ts admin/src/api/schema.d.ts createClient typed by generated paths createClient<paths>
from to via pattern
scripts/check-admin-openapi.sh admin/openapi/admin.json swag v1.16.6 then swagger2openapi then openapi-typescript requiredByDefault
[flagged-unverified] summercms.go (SPA source, SPA fixtures, the framework OpenAPI document and boardwalk/dist) must not contain Płytarium or fonoteka names or domain words.
[flagged-unverified] A cookie-transport login or refresh response must never carry the JWT in its body, and SPA code must never read, store or log the token.
[flagged-unverified] The admin SPA must not load fonts, icons or scripts from any origin other than its own.
[flagged-unverified] Non-admin routes and the fonoteka parity document must not change beyond dropping the admin paths from docs/openapi.json.

Phase Goal

As a backend administrator, I want to open my project's own admin URL, log in and manage Albums, Artists, Collections, Genres and Styles through schema-driven lists, forms and the relation manager, so that I can administer the catalogue from one Go binary without the WinterCMS backend.

Prove the Phase 10 architecture with one production path before adding breadth: an admin opens the configurable admin URL (`backend.uri`), logs in through the embedded SPA over cookie transport, sees the permission-filtered navigation and reads the Genres list, with every API call typed from the framework-owned OpenAPI document. Then harden the session transport, add boot guards for the prefix, and give fonoteka the translations, icons and Collections menu item the shell needs.

Purpose: The tracer crosses every seam this phase changes (config prefix, route mount, auth transport, embedded static serving, OpenAPI to TypeScript generation, SPA runtime base, app plugin metadata) in one commit, so a dead end shows up after one slice instead of after four plans. Decisions implemented: D-01, D-02, D-03 (costly), D-04, D-06, D-07, D-10, D-11, D-15, D-16, D-19 (costly), D-25; D-28 fixes this plan's scope. Output: backend.uri + prefix mount, cookie/CSRF transport, boardwalk package with committed dist/, framework admin OpenAPI pipeline, admin/ Vite project (login, shell, read-only list), fonoteka lang/icons/Collections nav, assembled tracer and transport tests.

Repos: Task 1 is a human gate; Task 2 writes summercms.go and fonoteka.go; Task 3 writes summercms.go and fonoteka.go. Commit each repo separately; planning docs and code in separate commits; never add co-author tags.

<execution_context> @/.claude/gsd-core/workflows/execute-plan.md @/.claude/gsd-core/templates/summary.md </execution_context>

@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/10-admin-vue-spa/10-CONTEXT.md @.planning/phases/10-admin-vue-spa/10-RESEARCH.md @.planning/phases/10-admin-vue-spa/design/README.md @.planning/phases/09-backend-admin-authentication-and-schema-pipeline/09-12-SUMMARY.md @cabana/http.go @cabana/auth.go @cabana/admin_openapi.go @bouncer/jwt.go @surf/router.go @../fonoteka.go/scripts/check-openapi.sh @../fonoteka.go/scripts/swagger2openapi.go @../fonoteka.go/plugins/golem15/fonoteka/admin_navigation.go Existing contracts to preserve (read the files, do not re-derive them): - `cabana.Activate(app *backpack.App, plugins []party.Plugin) (*Routes, error)`; `cabana.Routes{Middleware pact.Middleware; Mount func(pact.Router)}` gains `Prefix string`. - `bouncer.NewBackendJWTGuard(secret string, users UserProvider, bl BlacklistStore, write func(http.ResponseWriter, error)) Guard` gains a trailing `cookieNames ...string`; existing call sites compile unchanged. `extractToken` already tries `Authorization: Bearer` first and cookies second. - `surf.BuildRouter(app, plugins) (*Router, error)` mounts cabana after plugin routes; `Router.GroupRaw(prefix, middleware, fn)` and `Get/Post/Put/Delete`; ServeMux patterns accept `{path...}`. - `pact.HasLang{ LangFS() fs.FS }` with layout `lang//.yaml`; the user plugin's `//go:embed lang` in `plugins/golem15/user/plugin.go` is the pattern. - `cabana.WriteData(w, status, data, meta)`, `cabana.WriteError(w, status, code, message)`; D-10 codes are fixed: unauthenticated, forbidden, not_found, validation_failed, conflict. - Phase 9 admin JSON: `NavigationEntry{code,label,icon,order,controller,sideMenu}`, `ListSchema`, `ListMeta{page,per_page,total,last_page}`, `/auth/me` profile `{id,login,email,first_name,last_name,is_superuser,role{id,code,name}}`.

Artifacts this phase produces

  • Config key backend.uri (file config/backend.yaml, env SUMMER_BACKEND__URI), backend.cookie_secure (default true)
  • cabana.DefaultAdminPrefix (/backend), cabana.AdminPrefix(app *backpack.App) (string, error), cabana.AdminCookieName (summer_admin), Routes.Prefix, service.prefix, service.apiBase()
  • cabana CSRF wrapper (requireAjax or equivalent) on every unsafe admin API route except POST /auth/login
  • Login/refresh cookie transport: body {token_type: "cookie", expires_in}; Bearer body unchanged
  • bouncer.NewBackendJWTGuard(..., cookieNames ...string)
  • boardwalk.Handler(prefix string, notFoundAPI http.Handler) (http.Handler, error); embedded boardwalk/dist
  • cabana.Envelope[T], cabana.ListEnvelope[T], cabana.AdminRecord, cabana.AdminProfile, swag general info SummerCMS Admin API, prefix-relative @Router paths
  • internal/tools/swagger2openapi command; scripts/check-admin-openapi.sh [--check]; scripts/check-admin-dist.sh
  • admin/ Vite project: scripts dev, build, typecheck, test, gen:api; admin/openapi/admin.json; admin/src/api/schema.d.ts; runtime meta summer-admin-base
  • SPA modules: runtime, router, i18n.t, icons, controllerRoutes, api/client, api/types, useAuth, useNavigation; components AppShell, PluginRail, SectionPanel, DataTable; views LoginView, ListView, NotFoundView
  • Boot errors: invalid backend.uri; controller vendor segment api/assets/login/settings; non-cabana route under the prefix; backend.cookie_secure: false in production
  • fonoteka: config/backend.yaml (uri: /plytadmin), admin.jwt.blacklist_grace: 30, Plugin.LangFS(), lang/{en,pl}/lang.yaml, lucide icon names, Collections side-menu item
  • Tests: TestPhase10TracerSPA, TestPhase10AdminAuth, TestPhase10LangCatalog, TestPhase10CookieAuth, TestPhase10CSRF, TestPhase10Prefix, TestPhase10AdminPrefixCollision, boardwalk tests, tests/smoke/tracer.smoke.test.ts; helpers adminAPI(rel) in cabana and fonoteka tests
Task 1: Verify npm package legitimacy before the admin SPA install .planning/phases/10-admin-vue-spa/10-RESEARCH.md (sections "Standard Stack" and "Package Legitimacy Audit") Stop before any npm install and present the exact pin list below to the user. Nothing is written or installed by this task. Task 2 writes admin/package.json with these exact versions (no caret ranges) and runs one install that produces admin/package-lock.json. `@lucide/vue` is used per D-07's intent (the lucide Vue family already in vue-fonoteka-app); the literal `lucide-vue-next` package is deprecated upstream in favour of `@lucide/vue`. No code yet. Pin list (runtime): vue 3.5.35 [SUS: too-new latest], vue-router 5.1.0 [SUS], reka-ui 2.9.10 [SUS], @lucide/vue 1.17.0 [SUS], openapi-fetch 0.17.0 [OK], @fontsource/dm-sans 5.3.0 [OK], @fontsource/dm-mono 5.3.0 [OK]. Pin list (dev): vite 7.3.5 [SUS], @vitejs/plugin-vue 6.0.8 [SUS], typescript 5.9.3 [OK], vue-tsc 3.3.11 [OK], tailwindcss 4.3.0 [OK], @tailwindcss/vite 4.3.0 [OK], vitest 3.2.7 [SUS], @vue/test-utils 2.4.11 [SUS], happy-dom 20.11.6 [SUS], openapi-typescript 7.13.0 [OK]. Every SUS flag is the "latest release is days old" signal; each pin equals a version already in the team's vue-fonoteka-app lockfile, and every version was confirmed to exist with npm view during planning. 1. For each SUS package open https://www.npmjs.com/package/NAME/v/VERSION (for example https://www.npmjs.com/package/vue/v/3.5.35 and https://www.npmjs.com/package/@lucide/vue/v/1.17.0). 2. Confirm the name is spelled exactly, the repository link points at the expected project (vuejs/core, vuejs/router, unovue/reka-ui, lucide-icons/lucide, vitejs/vite, vitejs/vite-plugin-vue, vitest-dev/vitest, vuejs/test-utils, capricorn86/happy-dom) and the version exists. 3. Confirm none of the listed packages declares its own install script. 4. Optionally compare with /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app lockfile versions. Type "approved" to install exactly these versions, or name the packages to drop or re-pin. The user replied "approved", or every package the user rejected is removed from or re-pinned in the list Task 2 installs before Task 2 starts. Task 2: Admin logs in through the embedded SPA and reads the Genres list end to end D-03 and D-19 re-key every admin route, test and the OpenAPI document to the prefix and change Phase 9's auth transport; the user already locked both, so they are flagged without a checkpoint. Phase 9 is executed: `test -f scripts/check-phase9.sh && test -f cabana/admin_openapi.go && test -f ../fonoteka.go/plugins/golem15/fonoteka/admin_phase09_e2e_test.go` succeeds, and Task 1 was approved. go.mod, .gitignore, bouncer/jwt.go, cabana/prefix.go, cabana/csrf.go, cabana/http.go, cabana/auth.go, cabana/admin_openapi.go, cabana/admin_paths_test.go, cabana/auth_test.go, cabana/security_coverage_test.go, cabana/security_test.go, cabana/crud_lifecycle_test.go, cabana/bulk_test.go, cabana/commands_test.go, cabana/phase09_contract_test.go, boardwalk/boardwalk.go, boardwalk/dist/**, internal/tools/swagger2openapi/main.go, scripts/check-admin-openapi.sh, admin/package.json, admin/package-lock.json, admin/index.html, admin/vite.config.ts, admin/vitest.config.ts, admin/tsconfig.json, admin/env.d.ts, admin/openapi/admin.json, admin/src/main.ts, admin/src/App.vue, admin/src/app/runtime.ts, admin/src/app/router.ts, admin/src/app/i18n.ts, admin/src/app/icons.ts, admin/src/app/controllerRoutes.ts, admin/src/api/client.ts, admin/src/api/schema.d.ts, admin/src/api/types.ts, admin/src/state/useAuth.ts, admin/src/state/useNavigation.ts, admin/src/styles/main.css, admin/src/components/shell/AppShell.vue, admin/src/components/shell/PluginRail.vue, admin/src/components/shell/SectionPanel.vue, admin/src/components/list/DataTable.vue, admin/src/views/LoginView.vue, admin/src/views/ListView.vue, admin/src/views/NotFoundView.vue, admin/tests/setup.ts, admin/tests/fixtures/navigation.json, admin/tests/fixtures/widgets.list-schema.json, admin/tests/fixtures/widgets.list.json, admin/tests/smoke/tracer.smoke.test.ts, ../fonoteka.go/config/backend.yaml, ../fonoteka.go/scripts/check-openapi.sh, ../fonoteka.go/docs/openapi.json, ../fonoteka.go/plugins/golem15/fonoteka/controllers/genre_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/admin_paths_test.go, ../fonoteka.go/plugins/golem15/fonoteka/admin_phase10_tracer_test.go, ../fonoteka.go/plugins/golem15/fonoteka/plugin_boot_test.go, ../fonoteka.go/plugins/golem15/fonoteka/admin_tracer_test.go, ../fonoteka.go/plugins/golem15/fonoteka/admin_auth_test.go, ../fonoteka.go/plugins/golem15/fonoteka/admin_albums_test.go, ../fonoteka.go/plugins/golem15/fonoteka/admin_artists_test.go, ../fonoteka.go/plugins/golem15/fonoteka/admin_collections_test.go, ../fonoteka.go/plugins/golem15/fonoteka/admin_genres_test.go, ../fonoteka.go/plugins/golem15/fonoteka/admin_styles_test.go, ../fonoteka.go/plugins/golem15/fonoteka/admin_metadata_test.go, ../fonoteka.go/plugins/golem15/fonoteka/admin_settings_test.go, ../fonoteka.go/plugins/golem15/fonoteka/admin_phase09_e2e_test.go, ../fonoteka.go/plugins/golem15/fonoteka/admin_phase09_security_test.go cabana/http.go, cabana/auth.go, cabana/admin_openapi.go, cabana/contracts.go, cabana/navigation.go, cabana/query.go, cabana/security_coverage_test.go, cabana/phase09_contract_test.go, bouncer/jwt.go, surf/router.go, compass/config.go, ../fonoteka.go/scripts/check-openapi.sh, ../fonoteka.go/scripts/swagger2openapi.go, ../fonoteka.go/plugins/golem15/fonoteka/plugin_boot_test.go, ../fonoteka.go/plugins/golem15/fonoteka/admin_tracer_test.go, .planning/phases/10-admin-vue-spa/design/README.md, .planning/phases/10-admin-vue-spa/10-RESEARCH.md (Patterns 1-4, 7, 9, Pitfalls 1-3, 7, 9, 12, Code Examples) Start with a failing assembled `TestPhase10TracerSPA` in `../fonoteka.go/plugins/golem15/fonoteka/admin_phase10_tracer_test.go`, then build the thinnest permanent path through every layer below. Standard library only on the Go side; no new Go module requirement.

(1) Prefix and mount, per D-02/D-03: add cabana/prefix.go with const DefaultAdminPrefix = "/backend" and AdminPrefix(app *backpack.App) (string, error) reading backend.uri: trim spaces, add a leading slash, strip trailing slashes, use the default when empty, and require one or more segments of lowercase letters, digits, - and _ starting with a letter or digit; / or any other shape is an activation error naming backend.uri. Store it on service.prefix, add service.apiBase() returning prefix plus /api/v1 (a zero service{} uses the default), set Routes.Prefix, and rewrite service.mount so every Phase 9 route is registered under apiBase() instead of the hardcoded /_admin/api/v1. Add a raw group at the prefix with no middleware holding GET "" and GET "/{path...}", both served by the boardwalk handler. adminIssuer becomes app.url plus prefix plus /api/v1/auth/login.

(2) Cookie transport and CSRF, per D-19: add AdminCookieName = "summer_admin", give bouncer.NewBackendJWTGuard a trailing variadic cookieNames ...string stored on the guard, and pass the cookie name from cabana.Activate. In login, when the request header X-Requested-With equals XMLHttpRequest, set the cookie (HttpOnly, Secure, SameSite=Strict, Path = prefix, Max-Age = refresh TTL in seconds) and write data {"token_type":"cookie","expires_in":ACCESS_TTL_SECONDS}; without the header keep the Phase 9 Bearer body byte-for-byte and set no cookie. Add cabana/csrf.go: a handler wrapper applied in mount to every POST, PUT and DELETE admin API route except POST /auth/login; a request carrying Authorization: Bearer ... passes, any other request must carry X-Requested-With: XMLHttpRequest or receives 403 with the fixed D-10 code forbidden (the Phase 9 D-10 vocabulary is not extended) before decoding, controller lookup or SQL. Refresh and logout transport semantics are completed in Task 3.

(3) Embedded serving, per D-01/D-02: create package boardwalk (boardwalk/boardwalk.go) with //go:embed all:dist (the all: prefix keeps underscore-prefixed Rollup chunks, Pitfall 1) and Handler(prefix string, notFoundAPI http.Handler) (http.Handler, error). At construction read dist/index.html once, return an error when the token __SUMMER_ADMIN_BASE__ is absent, replace every attribute prefix ="./ with =" + prefix + / and the token with the HTML-escaped prefix, and keep the bytes. Per request: a remainder equal to api or starting with api/ goes to notFoundAPI (cabana passes a handler that writes the D-10 not_found envelope, Pitfall 3); otherwise path.Clean the remainder and look it up in the embedded tree; an existing regular file is served with an explicit content type for .js (text/javascript; charset=utf-8), .css (text/css; charset=utf-8), .woff2 (font/woff2), .woff (font/woff), .svg, .json, falling back to mime.TypeByExtension; files under assets/ get Cache-Control: public, max-age=31536000, immutable; a directory is never listed; a missing path whose last segment has an extension is 404; anything else gets the rewritten index with Cache-Control: no-store. Every response sets X-Content-Type-Options: nosniff, Referrer-Policy: same-origin, X-Frame-Options: DENY, Content-Security-Policy: frame-ancestors 'none'; base-uri 'none'; object-src 'none'; script-src 'self' and X-Robots-Tag: noindex, nofollow. Add ignore ./admin/node_modules to go.mod (Pitfall 2) and /admin/node_modules/ to .gitignore.

(4) Framework OpenAPI, per D-15/D-16: at the top of cabana/admin_openapi.go add swag general info (@title SummerCMS Admin API, @version 1, @BasePath /, @securityDefinitions.apikey BackendBearer with @in header and @name Authorization, and a description stating that the SPA authenticates with the summer_admin cookie plus the X-Requested-With header). Rewrite every @Router to its prefix-relative path. Add Envelope[T any]{Data T json:"data"; Meta SuccessMeta json:"meta"}, ListEnvelope[T any]{Data T json:"data"; Meta ListMeta json:"meta"}, AdminRecord (a map[string]any) and AdminProfile (the profileOf shape with an optional role object), and type the six routes this tracer uses: login and refresh as Envelope[AdminLoginData] (token_type required, access_token and expires_in optional via omitempty), /auth/me as Envelope[AdminProfile], /navigation as Envelope[[]NavigationEntry], list schema as Envelope[ListSchema], list as ListEnvelope[[]AdminRecord]; the remaining routes keep their Phase 9 annotation until Plan 10-02. Copy ../fonoteka.go/scripts/swagger2openapi.go to internal/tools/swagger2openapi/main.go (package main, stdlib only). Add scripts/check-admin-openapi.sh (bash, set -euo pipefail, committed with the executable bit, npm --prefix admin ci when admin/node_modules is absent): run go run github.com/swaggo/swag/cmd/swag@v1.16.6 init --dir cabana --generalInfo admin_openapi.go --outputTypes json --requiredByDefault into a temp dir (add --parseDependency only if a documented type lives outside cabana), convert with go run ./internal/tools/swagger2openapi, then run the admin devDependency openapi-typescript on the result; without --check copy the outputs to admin/openapi/admin.json and admin/src/api/schema.d.ts, with --check compare them with diff -u against the committed files and exit non-zero on any difference. Repoint TestPhase09ContractInventory to read admin/openapi/admin.json with prefix-relative route keys. In fonoteka.go remove ../summercms.go/cabana from the --dir list in scripts/check-openapi.sh, drop the BackendBearer security definition from controllers/genre_controller.go if no fonoteka route still references it, and regenerate docs/openapi.json so admin paths leave the parity document (one owner per path).

(5) SPA scaffold, per D-01/D-06/D-07/D-10/D-11: create admin/ with the Task 1 pins installed by npm install --save-exact (commit package-lock.json, set engines.node to >=22.6, private: true, type: module) and scripts dev (vite), build (vue-tsc --noEmit then vite build), typecheck (vue-tsc --noEmit), test (vitest run), gen:api (openapi-typescript openapi/admin.json -o src/api/schema.d.ts). vite.config.ts: base: './' for build and / for serve, build.outDir: '../boardwalk/dist', emptyOutDir: true, no sourcemaps, plus a serve-only transformIndexHtml plugin that replaces the base token with SUMMER_ADMIN_DEV_PREFIX (default /backend) and a dev proxy from that prefix plus /api to SUMMER_ADMIN_DEV_TARGET (default http://localhost:8080). index.html has a meta element named summer-admin-base whose content is the literal token, a robots noindex meta, one module script pointing at /src/main.ts, and no inline script. tsconfig.json is strict and includes only src, tests and env.d.ts (config files stay outside the typecheck, so no Node types package is needed). vitest.config.ts uses happy-dom, include: ['tests/**/*.test.ts'], setupFiles: ['tests/setup.ts']. src/styles/main.css imports Tailwind v4, declares the class-based dark variant with @custom-variant dark (&:where(.dark, .dark *)), maps every design README token (colours, radii, control heights, shadows, ring) through @theme to CSS variables defined on :root (light) and .dark (dark) with the README values, and imports @fontsource/dm-sans weights 400, 500, 600, 700 and @fontsource/dm-mono 400 and 500 as whole-weight CSS files (Pitfall 9). Modules: src/app/runtime.ts reads the meta once and exports base and api (base plus /api/v1); src/api/client.ts creates the openapi-fetch client typed by the generated paths with baseUrl: runtime.api and credentials: 'same-origin', and middleware that sets X-Requested-With: XMLHttpRequest on every request and sends a 401 on any call other than login to the login route with the current full path as redirect; src/api/types.ts contains only type aliases onto generated components['schemas'] entries; src/state/useAuth.ts (login, me, user) and src/state/useNavigation.ts (load navigation, active plugin from the route's vendor and plugin segments, first permitted side-menu controller per plugin); src/app/i18n.ts exposes t(key, params) returning the loaded bundle string or the key itself, mirroring phrasebook's missing-key fallback (the bundle endpoint and its loading land in Plans 10-02 and 10-03); src/app/controllerRoutes.ts maps a controller ID a.b.c to /a/b/c and back; src/app/icons.ts is a map of named @lucide/vue imports covering the design README icon list plus Winter aliases (icon-archive, icon-circle, icon-list-ul, icon-tags, icon-user, icon-search, icon-cog, icon-users) and a neutral fallback, never a namespace import (bundle size, research Pattern 9). src/app/router.ts uses createWebHistory(runtime.base) with routes /login, /, /:vendor/:plugin/:controller and a catch-all NotFound; its guard sends an unauthenticated visitor to login with redirect and accepts a redirect value only when it starts with exactly one /. Components follow the design README: LoginView.vue (screen 1, role="alert" block and aria-invalid on failure), AppShell.vue, PluginRail.vue (nav with an aria-label from t, entries sorted by order, active item aria-current="page"), SectionPanel.vue (active plugin's side menu), DataTable.vue (schema columns and rows, read-only), ListView.vue (loads schema/list and the list for the route's controller), NotFoundView.vue. src/main.ts boots /auth/me (401 leads to login), then /navigation, then mounts. Templates use text interpolation only. Add tests/setup.ts, neutral fixtures (acme.demo.widgets, plugin label "Demo") and tests/smoke/tracer.smoke.test.ts proving: login posts the header with same-origin credentials and stores no token; navigation renders grouped by plugin; ListView renders fixture columns and rows. Run npm --prefix admin run build and commit boardwalk/dist.

(6) Keep both repos green: add cabana/admin_paths_test.go (adminAPI(rel) = DefaultAdminPrefix + /api/v1 + rel) and ../fonoteka.go/plugins/golem15/fonoteka/admin_paths_test.go (const testAdminPrefix = "/plytadmin", adminAPI(rel)); make fonoteka bootConfig set backend.uri to testAdminPrefix; replace every /_admin/api/v1 route literal in the listed cabana and fonoteka tests with the helper. The route inventory in cabana/security_coverage_test.go becomes method plus prefix-relative path, the mount check composes full paths through adminAPI, and the inventory also lists the two SPA routes as public non-API entries excluded from the OpenAPI inventory. Issuer strings passed to MintAudience in tests are not routes and may stay. Add ../fonoteka.go/config/backend.yaml with uri: /plytadmin and a comment naming SUMMER_BACKEND__URI.

(7) TestPhase10TracerSPA boots the assembled handler (bootConfig, real PostgreSQL, migrations), seeds a developer-role backend admin with golem15.fonoteka.access_genres and one Genre, and asserts in order: GET /plytadmin returns text/html with the meta content /plytadmin, Cache-Control: no-store and asset URLs under /plytadmin/assets/; GET of the first referenced .js asset returns 200 with a JavaScript content type; GET /plytadmin/golem15/fonoteka/genres returns index.html; GET /plytadmin/api/v1/nope returns 404 with code not_found in JSON; POST /plytadmin/api/v1/auth/login with X-Requested-With returns 200, a summer_admin cookie with HttpOnly, Secure, SameSite=Strict and Path=/plytadmin, and a body without the token string; GET /plytadmin/api/v1/navigation with only the cookie returns the genres controller; GET /plytadmin/api/v1/golem15/fonoteka/genres with only the cookie returns the seeded row; POST .../genres/bulk-delete with only the cookie and no header returns 403 forbidden and the row still exists; GET /_admin/api/v1/auth/me with a valid Bearer token does not return 200. (cd ../fonoteka.go && go test ./plugins/golem15/fonoteka -run '^TestPhase10TracerSPA$' -count=1 -v) && go vet ./... && go test ./cabana ./bouncer ./boardwalk -count=1 && npm --prefix admin run typecheck && npm --prefix admin test -- tests/smoke && scripts/check-admin-openapi.sh --check <fails_when>Any command exits non-zero; the fonoteka output lacks "--- PASS: TestPhase10TracerSPA" or shows "no tests to run" or a SKIP; vitest reports "No test files found" or a failed test; check-admin-openapi.sh prints a diff.</fails_when> <acceptance_criteria> - TestPhase10TracerSPA passes against real PostgreSQL and asserts every step listed in action item (7). - grep -rn '/_admin/api/v1' cabana/*.go ../fonoteka.go/plugins/golem15/fonoteka/*.go | grep -v MintAudience prints nothing (only issuer strings passed to MintAudience may keep the old text; no route literal remains).

- `grep -c '@Router /auth/login \[post\]' cabana/admin_openapi.go` prints 1 and `python3 -c "import json;d=json.load(open('admin/openapi/admin.json'));assert '/navigation' in d['paths'] and not any(p.startswith('/_admin') for p in d['paths'])"` exits 0.
- `python3 -c "import json;d=json.load(open('../fonoteka.go/docs/openapi.json'));assert not any('admin' in p for p in d['paths'])"` exits 0.
- `grep -c 'go:embed all:dist' boardwalk/boardwalk.go` prints 1 and `grep -c '__SUMMER_ADMIN_BASE__' admin/index.html` prints 1.
- `grep -rniE 'pl[yý]tarium|fonoteka|albumy' admin/src admin/tests admin/openapi boardwalk/dist` prints nothing.
- `grep -rn 'fonts.googleapis\|unpkg.com\|cdn.jsdelivr' admin/index.html admin/src boardwalk/dist` prints nothing.
- `grep -c 'ignore ./admin/node_modules' go.mod` prints 1.

</acceptance_criteria> A developer-role admin opens /plytadmin, logs in over the cookie, sees the filtered navigation and reads the real Genres list through the embedded SPA, with every API call typed from the framework OpenAPI document.

Task 3: Harden the session transport and prefix, and give fonoteka its admin copy and icons cabana/auth.go, cabana/http.go, cabana/prefix.go, cabana/registry.go, cabana/phase10_auth_test.go, surf/router.go, surf/admin_prefix_test.go, boardwalk/boardwalk_test.go, scripts/check-admin-dist.sh, admin/src/api/client.ts, admin/src/state/useAuth.ts, admin/tests/smoke/tracer.smoke.test.ts, boardwalk/dist/**, ../fonoteka.go/config/admin.yaml, ../fonoteka.go/plugins/golem15/fonoteka/lang.go, ../fonoteka.go/plugins/golem15/fonoteka/lang/en/lang.yaml, ../fonoteka.go/plugins/golem15/fonoteka/lang/pl/lang.yaml, ../fonoteka.go/plugins/golem15/fonoteka/admin_navigation.go, ../fonoteka.go/plugins/golem15/fonoteka/admin_settings.go, ../fonoteka.go/plugins/golem15/fonoteka/admin_metadata_test.go, ../fonoteka.go/plugins/golem15/fonoteka/admin_phase10_auth_test.go cabana/auth.go, cabana/http.go, cabana/prefix.go, cabana/registry.go, bouncer/refresh.go, bouncer/jwt.go, surf/router.go, compass/config.go, boardwalk/boardwalk.go, admin/src/api/client.ts, ../fonoteka.go/config/admin.yaml, ../fonoteka.go/plugins/golem15/fonoteka/admin_navigation.go, ../fonoteka.go/plugins/golem15/fonoteka/admin_settings.go, ../fonoteka.go/plugins/golem15/fonoteka/admin_metadata_test.go, ../fonoteka.go/plugins/golem15/user/plugin.go, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/lang/pl/lang.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/lang/en/lang.php - TestPhase10CookieAuth: cookie login body has token_type cookie and no token; Bearer login body is unchanged and sets no cookie; cookie refresh with the header rotates the cookie and returns no token; cookie refresh without the header is 403; Bearer refresh keeps the Phase 9 body; logout blacklists the jti, sends an expiring summer_admin cookie with the same Path, and the old cookie is then refused. - TestPhase10CSRF: every POST, PUT and DELETE route in the mounted inventory except login, called with only a cookie and no header, returns 403 forbidden and a spy proves the handler, decoder and database were not reached; the same call with Bearer or with the header proceeds. - TestPhase10Prefix: normalization table (" /acme-admin/ " becomes "/acme-admin"; empty becomes "/backend"), invalid values ("/", "/Admin", "/a b", "/../x") fail activation naming backend.uri; a custom prefix moves the API, SPA routes, cookie Path and issuer; a controller whose vendor segment is api, assets, login or settings fails activation; backend.cookie_secure false fails in production and drops Secure elsewhere. - TestPhase10AdminPrefixCollision: BuildRouter fails when a non-cabana plugin route sits at or under the prefix. - boardwalk tests: index rewrite, missing token error, every referenced asset exists in the embedded tree, api/ delegation, extension 404, cleaned traversal, no directory listing, font MIME types, cache and security headers, no inline script in index. - TestPhase10AdminAuth (fonoteka, assembled): the same cookie lifecycle through /plytadmin; config/backend.yaml uri equals testAdminPrefix. TestPhase10LangCatalog: every golem15.fonoteka::lang key referenced by the embedded admin YAML and by the navigation, permission and settings declarations resolves in pl and en. (1) Complete D-19 session semantics: `refresh` and `logout` read the token from `Authorization: Bearer` first, then the `summer_admin` cookie. A cookie-sourced refresh rotates the cookie (same attributes as login) and writes `{"token_type":"cookie","expires_in":...}`; a Bearer-sourced refresh keeps the Phase 9 body. `logout` blacklists the jti exactly as today and always writes an expiring `summer_admin` cookie (same Path, Max-Age -1). Add `backend.cookie_secure` (default true): false is honoured only when `app.Config.Environment()` is not `production`; in production it is an activation error. In fonoteka `config/admin.yaml` set `blacklist_grace: 30` with a comment that it covers concurrent refresh from two tabs (A10). In the SPA `client.ts`, a 401 on any call other than login and refresh triggers one shared in-flight refresh promise; the original request is replayed once (keep a clone of the Request for the replay); a second 401 or a failed refresh goes to the login route with `redirect`. `useAuth.ts` schedules a proactive refresh at 80 percent of `expires_in` from the login or refresh body.

(2) Boot guards: cabana activation fails when a controller ID's vendor segment is api, assets, login or settings (reserved SPA and API segments, research Gap 9). In surf.BuildRouter, after admin.Mount, return an error naming method, path and plugin when any route of a plugin other than summercms.cabana has a path equal to Routes.Prefix or starting with Routes.Prefix plus /.

(3) Write the tests in <behavior>: cabana/phase10_auth_test.go (TestPhase10CookieAuth, TestPhase10CSRF, TestPhase10Prefix on the existing cabana Testcontainers PostgreSQL helper where a database is needed), surf/admin_prefix_test.go, boardwalk/boardwalk_test.go (use the embedded dist and an fstest-free check of every script and link reference in the rewritten index), and fonoteka admin_phase10_auth_test.go (TestPhase10AdminAuth, TestPhase10LangCatalog). Extend tests/smoke/tracer.smoke.test.ts with the single-flight refresh case (two concurrent 401s cause one refresh call and two replays).

(4) fonoteka admin content, per D-11/D-25: port /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/lang/{en,pl}/lang.php to plugins/golem15/fonoteka/lang/{en,pl}/lang.yaml with the same nested keys and values (group lang, so keys resolve as golem15.fonoteka::lang.*), and add plugins/golem15/fonoteka/lang.go with //go:embed lang and func (p *Plugin) LangFS() fs.FS plus a pact.HasLang assertion, following the user plugin. Switch icons to lucide names: plugin disc-3, albums disc-3, genres tags, styles palette, artists mic-vocal, settings search. Insert a Collections side-menu item directly after Albums: code collections, label golem15.fonoteka::lang.collection.menu_label, icon library, permission golem15.fonoteka.access_collections, controller golem15.fonoteka.collections (D-25, a documented deviation from the PHP navigation; navigation is not a parity surface). Update the expected navigation in TestAdminMetadataNavigation accordingly with a comment citing D-11 and D-25.

(5) Add scripts/check-admin-dist.sh (bash, set -euo pipefail, committed with the executable bit): run npm --prefix admin ci when admin/node_modules is absent, then the typecheck, then vite build --outDir TMPDIR/dist --emptyOutDir inside admin/, then diff -r TMPDIR/dist boardwalk/dist; exit non-zero on any difference. Rebuild boardwalk/dist after the SPA changes above. go test ./cabana -run '^TestPhase10(CookieAuth|CSRF|Prefix)$' -count=1 -v && go test ./surf -run '^TestPhase10AdminPrefixCollision$' -count=1 -v && go test ./boardwalk -count=1 -v && (cd ../fonoteka.go && go test ./plugins/golem15/fonoteka -run '^(TestPhase10AdminAuth|TestPhase10LangCatalog|TestAdminMetadataNavigation)$' -count=1 -v) && npm --prefix admin test -- tests/smoke && scripts/check-admin-dist.sh && scripts/check-phase9.sh --security <fails_when>Any command exits non-zero; any go test output shows "no tests to run", a SKIP line, or lacks a "--- PASS" line for each named test; check-admin-dist.sh prints a diff; check-phase9.sh prints a line starting with "refuse:".</fails_when> <acceptance_criteria> - Every behavior listed above has a named passing test; the Phase 9 security gate (scripts/check-phase9.sh --security) still passes with the new transport. - grep -c 'blacklist_grace: 30' ../fonoteka.go/config/admin.yaml prints 1. - TestAdminMetadataNavigation asserts the exact lucide icon of every navigation and settings entry, and grep -c '"library"' ../fonoteka.go/plugins/golem15/fonoteka/admin_navigation.go prints 1 and grep -c '"disc-3"' ../fonoteka.go/plugins/golem15/fonoteka/admin_navigation.go prints 2. - test -f ../fonoteka.go/plugins/golem15/fonoteka/lang/pl/lang.yaml &amp;&amp; test -f ../fonoteka.go/plugins/golem15/fonoteka/lang/en/lang.yaml succeeds and grep -c 'menu_label: Albumy' ../fonoteka.go/plugins/golem15/fonoteka/lang/pl/lang.yaml prints at least 1. - scripts/check-admin-dist.sh exits 0 on the committed tree. </acceptance_criteria> Cookie sessions refresh, rotate and log out safely across tabs; the prefix cannot collide with plugin routes or controller IDs; fonoteka's rail and list labels render in Polish with lucide icons and a Collections entry.

<threat_model>

Trust Boundaries

Boundary Description
Browser → {backend.uri}/api/v1 Untrusted requests carrying the admin cookie or a Bearer token and user-controlled headers
Browser → {backend.uri}/... static Untrusted paths resolved against the embedded dist tree
Plugin routes → admin prefix Other compiled plugins could register paths that shadow the admin surface
npm registry → admin/ build Third-party packages become code in the committed dist

STRIDE Threat Register

Threat ID Category Component Severity Disposition Mitigation Plan
T-10-01 Information Disclosure cabana login/refresh cookie transport high mitigate Cookie mode writes only token_type and expires_in; SPA never reads the token (HttpOnly); TestPhase10TracerSPA and TestPhase10CookieAuth assert the body carries no token.
T-10-02 Tampering cookie-authenticated unsafe admin routes (CSRF) high mitigate SameSite=Strict plus the cabana/csrf.go wrapper requiring X-Requested-With on every POST/PUT/DELETE without Bearer; TestPhase10CSRF walks the mounted inventory with a no-work spy.
T-10-03 Information Disclosure boardwalk static serving medium mitigate Embedded fs only, path.Clean, no directory listing, extension misses are 404, api/ misses return the JSON envelope; boardwalk tests cover traversal and fallback.
T-10-04 Tampering admin HTML responses (clickjacking, sniffing, indexing) medium mitigate X-Frame-Options DENY, CSP frame-ancestors none and script-src self, nosniff, Referrer-Policy same-origin, X-Robots-Tag noindex; no inline script in index; boardwalk tests assert every header.
T-10-05 Spoofing admin session cookie attributes and logout high mitigate HttpOnly, Secure, SameSite=Strict, Path=prefix, Max-Age=refresh TTL; logout blacklists the jti and expires the cookie; Secure opt-out refused in production; TestPhase10CookieAuth and TestPhase10AdminAuth.
T-10-06 Elevation of Privilege prefix and controller ID collisions medium mitigate Activation rejects malformed backend.uri and reserved vendor segments; BuildRouter rejects non-cabana routes under the prefix; TestPhase10Prefix and TestPhase10AdminPrefixCollision.
T-10-07 Denial of Service concurrent cookie refresh from two tabs low mitigate blacklist_grace 30 in fonoteka, single-flight refresh and one replay in the SPA; smoke test for concurrent 401s.
T-10-08 Tampering committed dist and generated types drift from source medium mitigate check-admin-dist.sh and check-admin-openapi.sh --check regenerate from the lockfile and fail on any diff.
T-10-17 Tampering login redirect parameter (open redirect) medium mitigate Router guard accepts redirect only when it starts with exactly one slash; smoke test covers an absolute URL value.
T-10-SC Tampering npm installs for admin/ high mitigate Task 1 blocking-human legitimacy checkpoint for every SUS package, exact version pins, committed package-lock.json, later installs via npm ci only.
</threat_model>
Run in summercms.go: `go vet ./... && go test ./...`, `npm --prefix admin run typecheck && npm --prefix admin test`, `scripts/check-admin-dist.sh`, `scripts/check-admin-openapi.sh --check`. Run in fonoteka.go: `go vet ./... && go test ./...`. Any non-zero exit, a skipped PostgreSQL test in a named Phase 10 test, or a printed diff fails the plan.

<success_criteria>

  • An admin reaches /plytadmin, logs in over an HttpOnly cookie, sees only permitted navigation and reads Genres through the embedded SPA (SC-1 slice).
  • Every admin route lives under backend.uri; the old prefix is gone from code and tests; the framework owns the admin OpenAPI document and the SPA's types come from it (SC-4 slice).
  • Cookie refresh, logout and CSRF behave as specified and the Phase 9 security gate still passes.
  • boardwalk/dist and the generated types are committed and drift-checked. </success_criteria>
Create `.planning/phases/10-admin-vue-spa/10-01-SUMMARY.md` when done.