docs(10): create phase plan

This commit is contained in:
Jakub Zych
2026-09-27 14:11:07 +02:00
parent 42c7216f5f
commit 8c3e131111
11 changed files with 1547 additions and 39 deletions

View File

@@ -424,9 +424,26 @@ Plans:
3. The Collections form's relation manager lets an admin search, link and unlink an editor.
4. API calls in the SPA use TypeScript types generated from the OpenAPI document, with no hand-maintained duplicate type.
**Plans**: TBD
**Plans**: 5 plans
**UI hint**: yes
Plans:
**Wave 1**
- [ ] 10-01-PLAN.md — Tracer: backend.uri prefix, cookie+CSRF transport, embedded SPA (login, navigation, typed Genres list), framework admin OpenAPI pipeline, fonoteka lang/icons/Collections nav
**Wave 2** *(blocked on Wave 1 completion)*
- [ ] 10-02-PLAN.md — Backend contract: relation options and saves with labels, backend strings bundle and overrides, messages, declarative toolbar, filter options, fully typed OpenAPI with conformance
**Wave 3** *(blocked on Wave 2 completion)*
- [ ] 10-03-PLAN.md — SPA lists, forms, filter bar and settings screen for all five controllers
**Wave 4** *(blocked on Wave 3 completion)*
- [ ] 10-04-PLAN.md — Relation manager with picker modal, and shell polish (flyout, collapse, user menu, breadcrumbs, dark mode)
**Wave 5** *(blocked on Wave 4 completion)*
- [ ] 10-05-PLAN.md — Unit tests last: Vitest and Go coverage, assembled acceptance, check-phase10.sh gate and security evidence
### Phase 11: Jobs, realtime and search infrastructure
**Goal**: River jobs run on the correct dual-driver split, Centrifugo publishing and channel authorization match the existing server, and Typesense sync stays a re-gated pre-filter — all brought up before the API phases that depend on them (album search needs Typesense sync, CSV import needs River jobs, notifications need the realtime publisher). River's dual-driver split and the Centrifugo/Typesense contracts are the least-implemented-and-verified parts of this research pass.

View File

@@ -1,21 +1,20 @@
---
gsd_state_version: 1.0
gsd_state_version: "1.0"
milestone: v1.0
milestone_name: milestone
current_phase: 09
current_phase_name: Backend admin authentication and schema pipeline
current_phase: 10
current_phase_name: Admin Vue SPA
status: executing
stopped_at: Completed 09-12-PLAN.md
last_updated: "2026-09-27T03:05:00.000Z"
last_updated: "2026-09-27T12:11:01.190Z"
last_activity: 2026-09-27
last_activity_desc: Phase 09 plan 12 acceptance gate completed
state_head: 10ca7a02e3feecd0974bbfa58902285bc9dc149f
state_head: 42c7216f5f8ebecbffdfa46e30d744af2590abff
progress:
total_phases: 15
completed_phases: 8
total_plans: 67
total_plans: 72
completed_plans: 67
percent: 100
milestone_name: milestone
---
# Project State
@@ -29,7 +28,7 @@ See: .planning/PROJECT.md (updated 2026-09-16)
## Current Position
Phase: 09 (Backend admin authentication and schema pipeline) — EXECUTING
Phase: 10 (Admin Vue SPA) — READY TO EXECUTE
Plan: 12 of 12
Status: Plan complete, verification not yet recorded
Last activity: 2026-09-27 — Phase 09 plan 12 acceptance gate completed

View File

@@ -0,0 +1,356 @@
---
phase: 10-admin-vue-spa
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- 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
autonomous: false
requirements: [ADMIN-06]
estimate:
tokens: 140000
raw_tokens: 140000
tasks: 3
confidence: low
must_haves:
truths:
- "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: "[flagged assumption A1] When backend.uri is unset the prefix is /backend (Winter's default); fonoteka sets /plytadmin in config/backend.yaml."
verification: backstop
- statement: "[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."
verification: backstop
- statement: "[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."
verification: backstop
artifacts:
- path: "cabana/prefix.go"
provides: "DefaultAdminPrefix and AdminPrefix(app) normalization and validation of backend.uri"
- path: "cabana/csrf.go"
provides: "X-Requested-With requirement for cookie-authenticated unsafe admin requests"
- path: "boardwalk/boardwalk.go"
provides: "Embedded dist serving with one-time index base injection, SPA fallback and api/ JSON 404 delegation"
- path: "internal/tools/swagger2openapi/main.go"
provides: "Swagger 2 to OpenAPI 3.0 converter for the framework admin document"
- path: "scripts/check-admin-openapi.sh"
provides: "Admin OpenAPI generation and --check drift gate"
- path: "scripts/check-admin-dist.sh"
provides: "Committed dist drift gate"
- path: "admin/src/api/client.ts"
provides: "Typed openapi-fetch client with runtime base URL, CSRF header and 401 handling"
- path: "admin/src/views/ListView.vue"
provides: "Schema-driven read-only list rendering for any controller"
- path: "../fonoteka.go/plugins/golem15/fonoteka/admin_phase10_tracer_test.go"
provides: "Assembled PostgreSQL proof of SPA serving, cookie login, navigation and Genres list"
key_links:
- from: "surf/router.go"
to: "cabana/http.go"
via: "BuildRouter mounts cabana routes and rejects non-cabana routes under Routes.Prefix"
pattern: "Prefix"
- from: "cabana/http.go"
to: "boardwalk/boardwalk.go"
via: "GET {prefix} and GET {prefix}/{path...} served by boardwalk.Handler with a D-10 not_found delegate"
pattern: "boardwalk.Handler"
- from: "cabana/auth.go"
to: "bouncer/jwt.go"
via: "NewBackendJWTGuard receives the summer_admin cookie name; Bearer still wins"
pattern: "AdminCookieName"
- from: "admin/src/api/client.ts"
to: "admin/src/api/schema.d.ts"
via: "createClient typed by generated paths"
pattern: "createClient<paths>"
- from: "scripts/check-admin-openapi.sh"
to: "admin/openapi/admin.json"
via: "swag v1.16.6 then swagger2openapi then openapi-typescript"
pattern: "requiredByDefault"
prohibitions:
- "[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.
<objective>
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.
</objective>
<execution_context>
@~/.claude/gsd-core/workflows/execute-plan.md
@~/.claude/gsd-core/templates/summary.md
</execution_context>
<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
<interfaces>
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/<locale>/<group>.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}}`.
</interfaces>
</context>
## 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
<tasks>
<task type="checkpoint:human-verify" gate="blocking-human">
<name>Task 1: Verify npm package legitimacy before the admin SPA install</name>
<read_first>.planning/phases/10-admin-vue-spa/10-RESEARCH.md (sections "Standard Stack" and "Package Legitimacy Audit")</read_first>
<action>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`.</action>
<what-built>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.</what-built>
<how-to-verify>
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.
</how-to-verify>
<resume-signal>Type "approved" to install exactly these versions, or name the packages to drop or re-pin.</resume-signal>
<acceptance_criteria>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.</acceptance_criteria>
</task>
<task type="tracer">
<name>Task 2: Admin logs in through the embedded SPA and reads the Genres list end to end</name>
<reversibility rating="costly">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.</reversibility>
<precondition>Phase 9 is executed: `test -f scripts/check-phase9.sh &amp;&amp; test -f cabana/admin_openapi.go &amp;&amp; test -f ../fonoteka.go/plugins/golem15/fonoteka/admin_phase09_e2e_test.go` succeeds, and Task 1 was approved.</precondition>
<files>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</files>
<read_first>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)</read_first>
<action>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.</action>
<verify>
<automated>(cd ../fonoteka.go &amp;&amp; go test ./plugins/golem15/fonoteka -run '^TestPhase10TracerSPA$' -count=1 -v) &amp;&amp; go vet ./... &amp;&amp; go test ./cabana ./bouncer ./boardwalk -count=1 &amp;&amp; npm --prefix admin run typecheck &amp;&amp; npm --prefix admin test -- tests/smoke &amp;&amp; scripts/check-admin-openapi.sh --check</automated>
<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>
</verify>
<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).
<!-- planner-discipline-allow: /_admin/api/v1 -->
- `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>
<done>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.</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: Harden the session transport and prefix, and give fonoteka its admin copy and icons</name>
<files>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</files>
<read_first>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</read_first>
<behavior>
- 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.
</behavior>
<action>(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.</action>
<verify>
<automated>go test ./cabana -run '^TestPhase10(CookieAuth|CSRF|Prefix)$' -count=1 -v &amp;&amp; go test ./surf -run '^TestPhase10AdminPrefixCollision$' -count=1 -v &amp;&amp; go test ./boardwalk -count=1 -v &amp;&amp; (cd ../fonoteka.go &amp;&amp; go test ./plugins/golem15/fonoteka -run '^(TestPhase10AdminAuth|TestPhase10LangCatalog|TestAdminMetadataNavigation)$' -count=1 -v) &amp;&amp; npm --prefix admin test -- tests/smoke &amp;&amp; scripts/check-admin-dist.sh &amp;&amp; scripts/check-phase9.sh --security</automated>
<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>
</verify>
<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>
<done>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.</done>
</task>
</tasks>
<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>
<verification>
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.
</verification>
<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>
<output>
Create `.planning/phases/10-admin-vue-spa/10-01-SUMMARY.md` when done.
</output>

View File

@@ -0,0 +1,334 @@
---
phase: 10-admin-vue-spa
plan: 02
type: execute
wave: 2
depends_on: [10-01]
files_modified:
- pact/capabilities.go
- phrasebook/lang.go
- phrasebook/loader.go
- phrasebook/translator.go
- phrasebook/backend/lang/en/lang.yaml
- phrasebook/backend/lang/pl/lang.yaml
- phrasebook/phase10_test.go
- cabana/relation_field.go
- cabana/relation_field_test.go
- cabana/form_schema.go
- cabana/form_schema_test.go
- cabana/list_schema.go
- cabana/list_schema_test.go
- cabana/filter_schema.go
- cabana/relation.go
- cabana/relation_test.go
- cabana/registry.go
- cabana/crud.go
- cabana/http.go
- cabana/messages.go
- cabana/lang.go
- cabana/messages_test.go
- cabana/filter_options_test.go
- cabana/openapi_conformance_test.go
- cabana/admin_openapi.go
- cabana/security_coverage_test.go
- cabana/phase09_contract_test.go
- internal/build/stubs/artifacts.tmpl
- internal/tools/swagger2openapi/main.go
- admin/openapi/admin.json
- admin/src/api/schema.d.ts
- admin/src/api/types.ts
- admin/src/views/ListView.vue
- admin/src/state/useAuth.ts
- admin/src/state/useNavigation.ts
- boardwalk/dist/**
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/albums_admin_controller.go
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/collections_admin_controller.go
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/albums/config_list.yaml
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/albums/config_form.yaml
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/artists/config_list.yaml
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/artists/config_form.yaml
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/collections/config_list.yaml
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/collections/config_form.yaml
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/collections/config_relation.yaml
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/genres/config_list.yaml
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/genres/config_form.yaml
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/styles/config_list.yaml
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/styles/config_form.yaml
- ../fonoteka.go/plugins/golem15/fonoteka/lang/en/lang.yaml
- ../fonoteka.go/plugins/golem15/fonoteka/lang/pl/lang.yaml
- ../fonoteka.go/plugins/golem15/fonoteka/admin_phase10_relations_test.go
- ../fonoteka.go/plugins/golem15/fonoteka/admin_phase10_copy_test.go
- ../fonoteka.go/plugins/golem15/fonoteka/admin_phase10_controllers_test.go
- ../fonoteka.go/plugins/golem15/fonoteka/admin_phase10_auth_test.go
- ../fonoteka.go/plugins/golem15/fonoteka/admin_albums_test.go
- ../fonoteka.go/plugins/golem15/fonoteka/admin_collections_test.go
autonomous: true
requirements: [ADMIN-06]
estimate:
tokens: 120000
raw_tokens: 120000
tasks: 3
confidence: low
must_haves:
truths:
- "Per D-17, GET {prefix}/api/v1/{vendor}/{plugin}/{controller}/fields/{field}/options?search=&page=&per_page= returns rows {value: numeric id, label: nameFrom value} with D-11 list meta, behind the controller permission, narrowed by the optional RelationExtendOptionsQuery hook; non-relation and read-only fields answer 404."
- "Per D-18, saving an album with {\"genre\": 3, \"artists\": [4, 9]} sets genre_id and replaces the album's artist pivot rows in array order inside the save transaction after FormBeforeCreate/FormBeforeUpdate; show, create and update responses carry the same shape in data plus meta.labels with {value, label} per relation field."
- "Per D-18 and Pitfall 6, a submitted relation id that the scoped options query would not return (unknown, out of scope, or duplicated) yields 422 validation_failed on that field and rolls back the whole save, including scalar changes."
- "Per D-26, the Collections owner field is served with readOnly true, its options endpoint answers 404, a submitted owner value never changes owner_id, and protectedFillKey and FormBeforeCreate are unchanged."
- "Per D-20, GET {prefix}/api/v1/lang (public) returns every backend::lang key for the request locale as a CLDR form map ({\"other\": ...} for plain strings) with meta.locale; a plugin implementing pact.HasLangOverrides can replace a key or add a locale without a Node rebuild."
- "Per D-13/D-24, config_list, config_form and config_relation accept an optional messages block of phrase keys whose texts use :count, :name and :term placeholders; every served schema carries a complete resolved messages object with defaults filled and plural keys as all their CLDR forms; an unknown messages key fails at boot."
- "Per D-14, toolbar.buttons is an ordered list of create and delete; the Winter string form (buttons: list_toolbar) fails at boot with a message naming the list syntax; delete without showCheckboxes, duplicates and unknown actions fail at boot; the five fonoteka controllers declare [create, delete]."
- "Per D-27, a model-backed filter scope's choices come from the model's FilterOptions(scope) and are served at {prefix}/api/v1/{vendor}/{plugin}/{controller}/filters/{scope}/options behind the controller permission; a scope filter whose model lacks FilterOptions fails at boot."
- "Per D-15/D-16, every admin route in admin/openapi/admin.json has a typed success schema, jsonScalar and fieldContext are emitted as unions, and TestPhase10OpenAPIConformance decodes each real handler response into its documented Go type with unknown fields disallowed."
- "Per D-08, TestPhase10Controllers reads the list schema, list, form schema and one record of each of Albums, Artists, Collections, Genres and Styles through /plytadmin/api/v1 with a cookie, and the fields and columns match exactly what the tracked YAML declares."
- statement: "[flagged assumption A8] The artist pivot sort_order is set to the submitted array index; the API is not a parity surface."
verification: backstop
- statement: "[flagged decision] fonoteka implements no RelationExtendOptionsQuery because golem15_fonoteka_genres and golem15_fonoteka_artists carry no collection column; the hook is proven with cabana acme fixtures."
verification: backstop
- statement: "[flagged decision] Required relation fields keep Phase 9 decision 304: required is a schema hint rendered by the SPA, not a save-time rule, matching the PHP Album rules."
verification: backstop
artifacts:
- path: "cabana/relation_field.go"
provides: "FieldRelationContract compile, options query, id revalidation, FK assignment, ordered pivot sync and labels"
- path: "cabana/messages.go"
provides: "Typed list/form/relation messages blocks with framework defaults and CLDR form resolution"
- path: "cabana/lang.go"
provides: "Public backend::lang string bundle handler"
- path: "phrasebook/backend/lang/pl/lang.yaml"
provides: "Framework Polish admin strings"
- path: "cabana/openapi_conformance_test.go"
provides: "Doc-versus-wire conformance for every admin route"
- path: "../fonoteka.go/plugins/golem15/fonoteka/admin_phase10_controllers_test.go"
provides: "Five-controller assembled contract through the prefix"
key_links:
- from: "cabana/crud.go"
to: "cabana/relation_field.go"
via: "save applies present relation keys after the Before hook and before the row write; show projects values and labels"
pattern: "FieldRelation"
- from: "cabana/relation_field.go"
to: "pact.RelationExtendOptionsQuery"
via: "the same scoped query serves options and revalidates submitted ids"
pattern: "RelationExtendOptionsQuery"
- from: "phrasebook/translator.go"
to: "cabana/lang.go"
via: "Translator.Bundle(locale, \"backend::lang.\") of Forms maps"
pattern: "Bundle"
- from: "cabana/admin_openapi.go"
to: "admin/src/api/schema.d.ts"
via: "scripts/check-admin-openapi.sh, run at the end of each of Tasks 1-3 so every task commits a drift-clean document and types"
pattern: "Envelope"
prohibitions:
- "[flagged-unverified] A relation save must not write a foreign key in the protected fill-key set, and must not attach a related row that the scoped options query would not return."
- "[flagged-unverified] The public string bundle must not expose any namespace other than backend::lang."
- "[flagged-unverified] Framework code must not name a plugin table, pivot, foreign key or label column; they come only from the controller's field relation contract."
- "[flagged-unverified] Existing Phase 9 security assertions (no collection_id or user_id leak, owner forced by FormBeforeCreate) must not be weakened to make relation JSON pass."
---
## 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.
<objective>
Grow the `cabana` admin API into the full contract the SPA screens need: relation field options and relation saves with labels, the framework `backend::lang` strings with an override layer and a public bundle, per-controller `messages`, the declarative toolbar, model-backed filter options, and a fully typed OpenAPI document proven against the wire. Update fonoteka's YAML, controllers and lang to the new contract.
Purpose: Plans 10-03 and 10-04 render only what this API serves, so every rule (scoping, read-only owner, plural copy, toolbar actions, filter choices) must be enforced and typed here. Decisions implemented: D-08, D-13, D-14 (costly), D-15, D-16, D-17, D-18, D-20, D-24, D-26, D-27; D-28 fixes this plan's scope.
Output: new cabana files and routes, phrasebook backend namespace and override layer, regenerated admin OpenAPI and TS types, fonoteka YAML/lang/controller updates and assembled tests.
Repos: every task writes summercms.go and fonoteka.go. Commit per repo; planning docs and code in separate commits; never add co-author tags.
Shared OpenAPI files across tasks: `cabana/http.go` and `cabana/admin_openapi.go` are hand-edited and grow additively (Task 1 adds the field options route and `RecordEnvelope`; Task 2 adds `/lang` and the `messages`/`toolbarButtons`/`redirects` schema fields; Task 3 adds the filter options route and types every remaining route). `admin/openapi/admin.json` and `admin/src/api/schema.d.ts` are never hand-edited: each task that changes an annotation or a documented Go type regenerates them with `scripts/check-admin-openapi.sh`, commits them in the same commit as that change, and ends drift-clean (`scripts/check-admin-openapi.sh --check` prints no diff) before the next task starts.
</objective>
<execution_context>
@~/.claude/gsd-core/workflows/execute-plan.md
@~/.claude/gsd-core/templates/summary.md
</execution_context>
<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/10-01-SUMMARY.md
@cabana/relation.go
@cabana/crud.go
@cabana/form_schema.go
@cabana/list_schema.go
@cabana/filter_schema.go
@phrasebook/loader.go
@phrasebook/translator.go
@pact/capabilities.go
@lagoon/relations.go
<interfaces>
From Plan 10-01: `service.apiBase()`, `adminAPI(rel)` test helpers in cabana and fonoteka, `Envelope[T]`, `ListEnvelope[T]`, `AdminRecord`, `scripts/check-admin-openapi.sh [--check]`, CSRF wrapper on unsafe routes, cookie `summer_admin`.
Existing Phase 9 contracts: `RelationContract` and `AdminRelationContractProvider` (relation manager), `pact.ListRelationColumnMapper`, `pact.FilterScope{FilterScopes(); FilterScope(name, db, value)}` on the model, `pact.DropdownOptionsProvider`, `normalizeIDs`, `escapeLike`, `modelColumns`, `protectedFillKey`, `formBeforeCreate/Update`, `lagoon.Fill`, `lagoon.Validate`, `CRUDService.Show/Create/Update`, `phrasebook.Catalog.Load(namespace, fs)`, `entry{text, plurals, pipes}`, `interpolate` with `:name`, `:Name`, `:NAME`.
Phase 5 join-table contract (lagoon/relations.go): pivot writes are explicit delete then bulk insert in the parent's transaction; never GORM Association().Replace().
</interfaces>
</context>
## Artifacts this phase produces
- `pact.RelationExtendOptionsQuery{ RelationExtendOptionsQuery(ctx, field string, db *gorm.DB) *gorm.DB }`, `pact.HasLangOverrides{ LangOverridesFS() fs.FS }` (layout `lang/<locale>/<namespace>/<group>.yaml`), `pact.FilterOptions{ FilterOptions(scope string) []Option }`
- `cabana.FieldRelationContract{Field, Kind, NewRelated, ForeignKey, NewPivot, ParentForeignKey, RelatedForeignKey, OrderColumn, LabelColumn}`, `cabana.FieldRelationProvider{ AdminFieldRelations() []FieldRelationContract }`, `cabana.RelationOption{Value uint; Label string}`, `cabana.RecordMeta{Labels map[string][]RelationOption}`, `cabana.RecordEnvelope`
- `FormField.Multiple` (`multiple`), `FormField.ReadOnly` (`readOnly`); `FormView.Messages`, `FormView.Redirects`; `ListSchema.Messages`; `RelationSchema.Messages`
- Routes: `GET /{vendor}/{plugin}/{controller}/fields/{field}/options`, `GET /{vendor}/{plugin}/{controller}/filters/{scope}/options`, `GET /lang` (public)
- `phrasebook` namespace `backend` (`phrasebook/backend/lang/{en,pl}/lang.yaml`), `(*Catalog).Override`, `(*Translator).Forms(locale, key) (map[string]string, bool)`, `(*Translator).Bundle(locale, prefix string) map[string]map[string]string`
- `messages` vocabulary: list `recordCount, create, searchPrompt, empty, emptySearch, emptySearchHint, selected, deleteSelected, deleteConfirm, deleted`; form `create, update, saved, deleteConfirm, deleted`; relation `link, linkHint, candidateSearch, linked, unlinkSelected, unlinkConfirm, unlinked, empty`
- Toolbar compile of `toolbar.buttons` list with boot errors; scaffold stub emits the list syntax
- OpenAPI converter union rewrite for `cabana.jsonScalar` and `cabana.fieldContext`
- fonoteka: `AdminFieldRelations()` on albums (genre, artists) and collections (owner); `messages` and `toolbar.buttons` in all five configs; new lang keys; tests `TestPhase10AlbumRelations`, `TestPhase10CollectionOwnerReadOnly`, `TestPhase10ControllerCopy`, `TestPhase10Controllers`
- cabana tests `TestPhase10RelationOptions`, `TestPhase10RelationSave`, `TestPhase10RelationForgedID`, `TestPhase10RelationBoot`, `TestPhase10Messages`, `TestPhase10Toolbar`, `TestPhase10Bundle`, `TestPhase10FilterOptions`, `TestPhase10OpenAPIConformance`; phrasebook `TestPhase10Forms`, `TestPhase10LangOverride`, `TestPhase10SPAKeysResolve`
<tasks>
<task type="tracer" tdd="true">
<name>Task 1: An admin picks an album's genre and artists from the options endpoint, saves them and reads them back with labels</name>
<files>pact/capabilities.go, cabana/relation_field.go, cabana/relation_field_test.go, cabana/form_schema.go, cabana/form_schema_test.go, cabana/registry.go, cabana/crud.go, cabana/http.go, cabana/admin_openapi.go, cabana/security_coverage_test.go, cabana/phase09_contract_test.go, admin/openapi/admin.json, admin/src/api/schema.d.ts, ../fonoteka.go/plugins/golem15/fonoteka/controllers/albums_admin_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/collections_admin_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/admin_phase10_relations_test.go, ../fonoteka.go/plugins/golem15/fonoteka/admin_albums_test.go, ../fonoteka.go/plugins/golem15/fonoteka/admin_collections_test.go</files>
<read_first>cabana/relation.go, cabana/crud.go, cabana/form_schema.go, cabana/registry.go, cabana/http.go, cabana/query.go, cabana/admin_openapi.go, pact/capabilities.go, lagoon/relations.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/albums_admin_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/collections_admin_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/models/album.go, ../fonoteka.go/plugins/golem15/fonoteka/models/album_artist.go, ../fonoteka.go/plugins/golem15/fonoteka/models/collection.go, ../fonoteka.go/plugins/golem15/fonoteka/models/album/fields.yaml, ../fonoteka.go/plugins/golem15/fonoteka/models/collection/fields.yaml, .planning/phases/10-admin-vue-spa/10-RESEARCH.md (Pattern 5, Pitfalls 6 and 13)</read_first>
<behavior>
- TestPhase10RelationOptions (acme fixtures, real PostgreSQL): search is case-insensitive on the label column with LIKE metacharacters escaped; order is label then id; per_page defaults to 20 and caps at 100; RelationExtendOptionsQuery narrows the rows; values are numbers; a principal without the controller permission gets 403 before SQL; a non-relation field and a read-only field get 404.
- TestPhase10RelationSave: belongsTo sets the foreign key; belongsToMany replaces pivot rows in submitted order with OrderColumn equal to the index; a missing key leaves the relation unchanged; null clears a nullable belongsTo; show returns ids plus meta.labels.
- TestPhase10RelationForgedID: an id outside the scoped query, a non-integer id and a duplicated id each return 422 on that field and nothing (scalar or pivot) is committed.
- TestPhase10RelationBoot: a relation field without a contract, a contract naming a missing column, and an unknown kind each fail activation naming plugin, controller and field.
- TestPhase10AlbumRelations (fonoteka, assembled, cookie through /plytadmin): options for genre and artists, create and update with genre and ordered artists, show with labels, forged artist id 422.
- TestPhase10CollectionOwnerReadOnly: owner is readOnly in the form schema, owner options is 404, a submitted owner id does not change owner_id, show carries the owner label.
</behavior>
<action>Start with failing `TestPhase10AlbumRelations` and `TestPhase10RelationSave`, then implement D-17, D-18 and D-26 in `cabana/relation_field.go`, extending the existing model-owned relation style (framework code never guesses a table or key).
Contract: add `FieldRelationContract{Field string; Kind string ("belongsTo" or "belongsToMany"); NewRelated func() any; ForeignKey string (belongsTo column on the parent); NewPivot func() any; ParentForeignKey, RelatedForeignKey string (belongsToMany pivot columns); OrderColumn string (optional pivot column set to the array index); LabelColumn string (physical label column; default = the field's nameFrom mapped through pact.ListRelationColumnMapper when the controller implements it)}` and `FieldRelationProvider{ AdminFieldRelations() []FieldRelationContract }` on the controller. At activation, every `type: relation` field must have exactly one contract; kinds, related/pivot models and every named column are validated with `modelColumns`; any failure is a boot error naming plugin, controller and field. Compile `FormField.Multiple` (json `multiple`, belongsToMany) and `FormField.ReadOnly` (json `readOnly`, a belongsTo whose ForeignKey is in `protectedFillKey`, per D-26; the fill-key list and FormBeforeCreate stay as they are).
Options (D-17): add `pact.RelationExtendOptionsQuery` and mount `GET {apiBase}/{vendor}/{plugin}/{controller}/fields/{field}/options` with a `field` constraint of `[A-Za-z_][A-Za-z0-9_]*`, served through `protect`; answer 404 `not_found` for a field that is not a writable relation. Query the related model, apply the hook when the controller implements it, filter `search` with ILIKE on the label column using `escapeLike`, order by label then primary key, paginate with the Phase 9 relation limits (default 20, max 100, reuse the relation query normalization), and write `ListEnvelope[[]RelationOption]` where `RelationOption{Value uint json:"value"; Label string json:"label"}`.
Save (D-18): before scalar projection, lift the keys of writable relation fields out of the body (other nested values are still dropped); only keys present in the body are applied. Inside the existing save transaction, after `formBeforeCreate`/`formBeforeUpdate`: normalize ids with `normalizeIDs`, reject duplicates, and re-run the same scoped options query with `WHERE <primary key> IN (...)`; any id it does not return is a 422 `validation_failed` on that field (the transaction rolls back everything). A belongsTo value (or null for a nullable FK) is assigned to the parent's FK field before `tx.Create`/`tx.Save`; a belongsToMany value is written after the row exists: delete the parent's pivot rows, then bulk insert one pivot model per id in submitted order with OrderColumn set to the index when declared (Phase 5 contract; never Association Replace). Then the After hooks run. Required relation fields keep Phase 9 decision 304 (schema hint only), matching the PHP Album rules. `Show`, `Create` and `Update` return the record with relation values in `data` (belongsTo id or null; belongsToMany ids in pivot order, then primary key) and `meta.labels` as field to `[]RelationOption` (read-only fields included); document them as `RecordEnvelope{Data AdminRecord; Meta RecordMeta}`.
fonoteka: albums controller declares genre (belongsTo, models.Genre, ForeignKey genre_id, LabelColumn name) and artists (belongsToMany, models.Artist via models.AlbumArtist, ParentForeignKey album_id, RelatedForeignKey artist_id, OrderColumn sort_order, LabelColumn name); collections controller declares owner (belongsTo, the user model, ForeignKey owner_id, LabelColumn email). fonoteka implements no RelationExtendOptionsQuery (genres and artists have no collection column); say so in a comment. Adjust Phase 9 album and collection tests only where they pin the old relation-field JSON or the absence of relation keys; keep every collection_id/user_id/owner assertion.
Add the two routes to the admin route inventory, annotate them in `cabana/admin_openapi.go` (the research's `AdminFieldOptions` example; show/create/update as `RecordEnvelope`), and run `scripts/check-admin-openapi.sh` to regenerate the committed document and types.
Regeneration step (end of task): after the last annotation or documented-type edit, run `scripts/check-admin-openapi.sh` once more, commit the regenerated `admin/openapi/admin.json` and `admin/src/api/schema.d.ts` together with this task's cabana changes, and confirm `scripts/check-admin-openapi.sh --check` prints no diff; the task ends drift-clean.</action>
<verify>
<automated>go test ./cabana -run '^TestPhase10Relation(Options|Save|ForgedID|Boot)$|^TestPhase09(PermissionMatrix|ContractInventory)$' -count=1 -v &amp;&amp; (cd ../fonoteka.go &amp;&amp; go test ./plugins/golem15/fonoteka -run '^(TestPhase10AlbumRelations|TestPhase10CollectionOwnerReadOnly|TestAlbumsAdmin.*|TestCollectionsAdmin.*)$' -count=1) &amp;&amp; scripts/check-admin-openapi.sh --check</automated>
<fails_when>Any command exits non-zero; the cabana output lacks a "--- PASS" line for each of the four TestPhase10Relation tests or shows "no tests to run" or SKIP; the fonoteka run prints FAIL or "no tests to run"; check-admin-openapi.sh prints a diff.</fails_when>
</verify>
<acceptance_criteria>
- All six behaviors above pass against real PostgreSQL; Phase 9 album and collection suites still pass.
- `grep -c 'fields/{field}/options' cabana/http.go` prints 1 and `grep -c 'Association(' cabana/relation_field.go` prints 0.
- `python3 -c "import json;d=json.load(open('admin/openapi/admin.json'));assert '/{vendor}/{plugin}/{controller}/fields/{field}/options' in d['paths']"` exits 0.
- `grep -rniE 'golem15|album' cabana/relation_field.go` prints nothing.
</acceptance_criteria>
<done>An admin can fetch genre and artist choices, save an album's genre and ordered artists, and read them back with labels; forged or out-of-scope ids are rejected and the Collections owner cannot be reassigned.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Controllers speak their own copy through backend strings, messages and the declarative toolbar</name>
<files>pact/capabilities.go, phrasebook/lang.go, phrasebook/loader.go, phrasebook/translator.go, phrasebook/backend/lang/en/lang.yaml, phrasebook/backend/lang/pl/lang.yaml, phrasebook/phase10_test.go, cabana/messages.go, cabana/lang.go, cabana/list_schema.go, cabana/list_schema_test.go, cabana/form_schema.go, cabana/relation.go, cabana/relation_test.go, cabana/http.go, cabana/admin_openapi.go, cabana/messages_test.go, cabana/security_coverage_test.go, internal/build/stubs/artifacts.tmpl, admin/openapi/admin.json, admin/src/api/schema.d.ts, ../fonoteka.go/plugins/golem15/fonoteka/controllers/albums/config_list.yaml, ../fonoteka.go/plugins/golem15/fonoteka/controllers/albums/config_form.yaml, ../fonoteka.go/plugins/golem15/fonoteka/controllers/artists/config_list.yaml, ../fonoteka.go/plugins/golem15/fonoteka/controllers/artists/config_form.yaml, ../fonoteka.go/plugins/golem15/fonoteka/controllers/collections/config_list.yaml, ../fonoteka.go/plugins/golem15/fonoteka/controllers/collections/config_form.yaml, ../fonoteka.go/plugins/golem15/fonoteka/controllers/collections/config_relation.yaml, ../fonoteka.go/plugins/golem15/fonoteka/controllers/genres/config_list.yaml, ../fonoteka.go/plugins/golem15/fonoteka/controllers/genres/config_form.yaml, ../fonoteka.go/plugins/golem15/fonoteka/controllers/styles/config_list.yaml, ../fonoteka.go/plugins/golem15/fonoteka/controllers/styles/config_form.yaml, ../fonoteka.go/plugins/golem15/fonoteka/lang/en/lang.yaml, ../fonoteka.go/plugins/golem15/fonoteka/lang/pl/lang.yaml, ../fonoteka.go/plugins/golem15/fonoteka/admin_phase10_copy_test.go, ../fonoteka.go/plugins/golem15/fonoteka/admin_phase10_auth_test.go</files>
<read_first>phrasebook/loader.go, phrasebook/translator.go, phrasebook/lang.go, cabana/list_schema.go, cabana/form_schema.go, cabana/relation.go, cabana/schema.go, cabana/http.go, internal/build/stubs/artifacts.tmpl, /media/nvme/dev/golem15/fonoteka/modules/backend/lang/pl/lang.php, /media/nvme/dev/golem15/fonoteka/modules/backend/lang/en/lang.php, .planning/phases/10-admin-vue-spa/design/README.md (all copy), .planning/phases/10-admin-vue-spa/10-RESEARCH.md (Pattern 6, Pitfalls 4 and 5), ../fonoteka.go/plugins/golem15/fonoteka/controllers/albums/config_list.yaml, ../fonoteka.go/plugins/golem15/fonoteka/controllers/collections/config_relation.yaml</read_first>
<behavior>
- TestPhase10Forms: plain text maps to {"other": text}; a YAML plural map copies all CLDR forms; category-only pipes map by category; an exact or range pipe is not convertible (ok false).
- TestPhase10LangOverride: an override FS replaces an existing backend key, adds a key, and adds a new locale; a malformed override path fails activation; a backend key that cannot convert to forms (exact or range pipe) fails activation naming the key.
- TestPhase10Bundle: GET {prefix}/api/v1/lang without auth returns only backend::lang keys for the Accept-Language locale with fallback to app.fallback_locale, meta.locale set, Cache-Control no-cache; no plugin or lagoon key appears.
- TestPhase10Messages: omitted keys are filled with framework defaults; a plugin key resolves in pl and en; plural keys arrive as all forms; an unknown messages key fails at boot through DisallowUnknownField; a messages value naming a missing phrase key fails at boot when a translator is available.
- TestPhase10Toolbar: [create, delete] compiles in order; buttons: list_toolbar fails with a message containing "toolbar.buttons must be a list"; delete without showCheckboxes, duplicates and unknown actions fail; create is omitted from the served list when the controller has no compiled form.
- TestPhase10ControllerCopy (fonoteka, assembled): each of the five list schemas serves toolbarButtons ["create","delete"] and a complete Polish messages object; the album form serves create, update and saved; the editors relation schema serves link and linked with Polish plural forms.
- TestPhase10SPAKeysResolve: every backend::lang key literal found in admin/src resolves in both pl and en.
</behavior>
<action>(1) Backend strings, per D-20: add `phrasebook/backend/lang/{en,pl}/lang.yaml` embedded by `phrasebook/lang.go` and loaded in `Activate` as namespace `backend` right after `lagoon`, so keys resolve as `backend::lang.<group>.<key>`. Keep Winter key names where Winter has them (list.search_prompt, list.no_records, list.delete_selected, list.loading, list.prev_page, list.next_page, list.records_per_page, list.column_switch_true, list.column_switch_false, form.save, form.save_and_close, form.cancel, form.delete, form.none, form.update, form.create, form.return_to_list, form.close, relation.add, relation.link, relation.unlink, relation.remove) and add the SPA's own keys under auth.*, nav.*, list.*, form.*, relation.*, messages.* for every string in the design README (login copy, navigation aria labels, pagination range `:from–:to z :total`, results count, empty and empty-search states, selection pill, 422 banner, unsupported field `Nieobsługiwany typ pola: :type`, toast close, modal copy, Dodaj (:count)). Polish values follow the design README copy (D-06); English values are plain equivalents. Every plural text is a YAML CLDR map (one, few, many, other for pl; one, other for en), never a pipe string. Placeholders use `:count`, `:name`, `:term` per D-24. Framework message defaults per D-13: list create "Nowy rekord", deleteConfirm "Usunąć zaznaczone (:count)?", form saved "Zapisano", plus defaults for every other vocabulary key.
(2) Override layer and bundle: add `pact.HasLangOverrides` and `(*Catalog).Override(owner string, fsys fs.FS) error` reading `lang/<locale>/<namespace>/<group>.yaml`; it runs after every namespace is loaded, may replace existing keys and add locales, and fails on malformed paths. After overrides, `Activate` fails when any `backend::` key cannot convert to forms. Add `(*Translator).Forms(locale, key string) (map[string]string, bool)` (text → other; plural map → copy; category-only pipes → by category; exact/range pipes → false) and `(*Translator).Bundle(locale, prefix string) map[string]map[string]string` merging fallback-locale keys under requested-locale keys. Add `cabana/lang.go` and mount public `GET {apiBase}/lang` next to the auth routes (no guard; GET needs no CSRF header): data is the bundle for `backend::lang.`, meta.locale is the resolved locale, header `Cache-Control: no-cache`; document it as `Envelope[map[string]map[string]string]`.
(3) Messages, per D-13/D-24: `cabana/messages.go` defines fixed structs `listMessages{RecordCount, Create, SearchPrompt, Empty, EmptySearch, EmptySearchHint, Selected, DeleteSelected, DeleteConfirm, Deleted}`, `formMessages{Create, Update, Saved, DeleteConfirm, Deleted}` and `relationMessages{Link, LinkHint, CandidateSearch, Linked, UnlinkSelected, UnlinkConfirm, Unlinked, Empty}` with lowerCamel yaml tags, decoded strictly so an unknown key fails at boot. `config_list.yaml` and `config_form.yaml` gain `messages:`; `config_relation.yaml` gains `messages:` per relation (applying the D-13 rules to relation copy; Claude's discretion on vocabulary). Omitted keys take the framework default key; the list `searchPrompt` default is the existing `toolbar.search.prompt` when set. At activation, when a translator is published, every message key must exist in the catalog. Cached schemas keep keys; each response localizes to `messages` as key → CLDR form map (via Forms). `FormView` also serves `redirects` with the raw Winter `create.redirect`, `create.redirectClose`, `update.redirect`, `update.redirectClose` strings (the SPA maps them in Plan 10-03).
(4) Toolbar, per D-14: `listToolbar.Buttons` becomes an ordered list type with a custom `UnmarshalYAML(ast.Node)` (the `fieldMap`/`scopeMap` pattern) that rejects a scalar with `toolbar.buttons must be a list of actions (create, delete); the Winter partial "list_toolbar" is not supported`; accepted actions are exactly `create` and `delete`; duplicates, unknown actions and `delete` without `showCheckboxes: true` fail at boot. The compiled `ToolbarButtons` keeps declared order and omits `create` when the controller has no compiled form. Update the inline YAML fixtures in `cabana/list_schema_test.go` and the scaffold stub in `internal/build/stubs/artifacts.tmpl` (config_list gains `toolbar.buttons: [create]` and a search prompt) to the list syntax.
(5) fonoteka: set `toolbar.buttons: [create, delete]` in the five `config_list.yaml` files (each has `showCheckboxes: true`; keep `toolbar.search.prompt`), add `messages` blocks to the five list and form configs and to the editors relation, and add the referenced keys to `lang/{pl,en}/lang.yaml` under each existing group (item, artist, collection, genre, style) with natural Polish plurals (for example albums recordCount ":count pozycja w katalogu" / ":count pozycje w katalogu" / ":count pozycji w katalogu"; create "Nowy album"; searchPrompt "Szukaj albumów…"; editors link "Dodaj edytora" and linked "Dodano :count edytora" / "Dodano :count edytorów", matching the plugin's existing "Edytorzy" wording). Extend `TestPhase10LangCatalog` to also resolve every `backend::lang` key referenced by fonoteka YAML.
(6) Write the tests in `<behavior>` (`phrasebook/phase10_test.go` including TestPhase10SPAKeysResolve walking `../admin/src`, `cabana/messages_test.go`, fonoteka `admin_phase10_copy_test.go`), add `/lang` to the route inventory as public, and regenerate the admin document and types with `scripts/check-admin-openapi.sh`.
Regeneration step (end of task): the new `/lang` annotation and the `messages`, `toolbarButtons` and `redirects` fields on `ListSchema`, `FormView` and `RelationSchema` change the document, so after the last such edit run `scripts/check-admin-openapi.sh` once more, commit the regenerated `admin/openapi/admin.json` and `admin/src/api/schema.d.ts` together with this task's cabana and phrasebook changes, and confirm `scripts/check-admin-openapi.sh --check` prints no diff; the task ends drift-clean.</action>
<verify>
<automated>go test ./phrasebook ./cabana -run '^TestPhase10(Forms|LangOverride|SPAKeysResolve|Bundle|Messages|Toolbar)$|^TestPhase09(PermissionMatrix|ContractInventory)$' -count=1 -v &amp;&amp; go test ./internal/build -run '^Test.*AdminController' -count=1 &amp;&amp; (cd ../fonoteka.go &amp;&amp; go test ./plugins/golem15/fonoteka -run '^(TestPhase10ControllerCopy|TestPhase10LangCatalog)$' -count=1 -v) &amp;&amp; scripts/check-admin-openapi.sh --check</automated>
<fails_when>Any command exits non-zero; output shows "no tests to run" or SKIP for a named Phase 10 test, or lacks a "--- PASS" line for each of TestPhase10Forms, TestPhase10LangOverride, TestPhase10SPAKeysResolve, TestPhase10Bundle, TestPhase10Messages, TestPhase10Toolbar, TestPhase10ControllerCopy and TestPhase10LangCatalog; check-admin-openapi.sh prints a diff.</fails_when>
</verify>
<acceptance_criteria>
- `grep -rn 'list_toolbar' ../fonoteka.go/plugins/golem15/fonoteka/controllers internal/build/stubs` prints nothing and `grep -c 'buttons: \[create, delete\]' ../fonoteka.go/plugins/golem15/fonoteka/controllers/*/config_list.yaml` prints 1 for each of the five files.
<!-- planner-discipline-allow: list_toolbar -->
- Activation converts every `backend::` key to CLDR forms (a pipe-plural backend string fails boot), and TestPhase10LangOverride proves an unconvertible backend key fails activation naming the key.
- Every named behavior test passes; the bundle response contains no key outside `backend::lang.`.
</acceptance_criteria>
<done>Each controller's list, form and relation schema arrives with complete, locale-resolved copy and a declarative toolbar, and the SPA can load every framework string from one public bundle.</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: Filters get their choices and the whole admin API is typed and proven against the wire</name>
<files>pact/capabilities.go, cabana/filter_schema.go, cabana/http.go, cabana/admin_openapi.go, cabana/filter_options_test.go, cabana/openapi_conformance_test.go, cabana/list_schema_test.go, cabana/security_coverage_test.go, cabana/phase09_contract_test.go, internal/tools/swagger2openapi/main.go, admin/openapi/admin.json, admin/src/api/schema.d.ts, admin/src/api/types.ts, admin/src/views/ListView.vue, admin/src/state/useAuth.ts, admin/src/state/useNavigation.ts, boardwalk/dist/**, ../fonoteka.go/plugins/golem15/fonoteka/admin_phase10_controllers_test.go</files>
<read_first>cabana/filter_schema.go, cabana/query.go, cabana/admin_openapi.go, cabana/schema_types.go, cabana/settings.go, cabana/navigation.go, cabana/relation.go, internal/tools/swagger2openapi/main.go, cabana/testdata/list/all_filters.yaml, cabana/list_schema_test.go, admin/src/api/types.ts, admin/src/views/ListView.vue, .planning/phases/10-admin-vue-spa/10-RESEARCH.md (Pattern 4, Pitfalls 7 and 8)</read_first>
<behavior>
- TestPhase10FilterOptions: a scope filter serves the model's FilterOptions(scope) as [{value, label}] with labels localized; an undeclared scope name is 404; a principal without the controller permission is 403 before the provider runs; a scope filter whose model lacks FilterOptions fails activation.
- TestPhase10OpenAPIConformance: for every route in the admin inventory, the real handler response (httptest against a fixture registry) decodes into the Go type its annotation documents with DisallowUnknownFields, and every documented path exists in admin/openapi/admin.json with that schema reference.
- TestPhase10Controllers (fonoteka, assembled, cookie through /plytadmin): for Albums, Artists, Collections, Genres and Styles the list schema columns equal the tracked columns.yaml keys in order, the form schema fields equal the tracked fields.yaml keys in order, every served field type is one of the eight D-05 built-ins (text, textarea, number, checkbox, switch, dropdown, relation, relation-manager) so no real screen falls back to the unsupported box, the list returns data and meta, and one created record is readable with the same keys.
</behavior>
<action>(1) Filter choices, per D-27: add `pact.FilterOptions{ FilterOptions(scope string) []Option }` implemented by the model (the same model that implements `pact.FilterScope`). At activation a `type: scope` filter whose model lacks it fails with an error naming the scope and D-27; update the acme fixture model used by `cabana/list_schema_test.go` to implement it. Mount `GET {apiBase}/{vendor}/{plugin}/{controller}/filters/{scope}/options` through `protect` with a `scope` identifier constraint; 404 for a name that is not a declared scope filter of that controller's list; data is `[]FilterOption{Value string; Label string}` with labels passed through the translator; meta.locale.
(2) Full typing, per D-15/D-16: give every remaining admin route a concrete response type in `cabana/admin_openapi.go`: settings list `Envelope[[]SettingsEntry]`, settings schema and form schema `Envelope[FormView]`, settings GET/PUT `Envelope[SettingsResult]`, relation schema `Envelope[RelationSchema]`, relation linked/candidates `ListEnvelope[[]AdminRecord]`, link/unlink `Envelope[RelationMutationResult]`, bulk delete and delete `Envelope[BulkResult]`, logout `Envelope[AdminLogoutData]`, filter options `Envelope[[]FilterOption]`; every protected route documents 401, 403 and 404, write routes document 422, CSRF-protected routes document 403. Add `--requiredByDefault`-friendly `omitempty` only where a field is genuinely optional. In `internal/tools/swagger2openapi/main.go` rewrite the component for `cabana.jsonScalar` to a nullable oneOf of string, number and boolean, and `cabana.fieldContext` to a oneOf of string and array of string. Remove the untyped `SuccessEnvelope` from annotations once nothing references it. Regenerate with `scripts/check-admin-openapi.sh`.
(3) Conformance: `cabana/openapi_conformance_test.go` builds a fixture registry (acme names only), calls every inventoried handler with httptest (using the existing Testcontainers PostgreSQL helper where rows are needed), decodes each body into the documented Go envelope type with `DisallowUnknownFields`, and cross-checks the path and schema reference in `admin/openapi/admin.json`, so the document cannot drift from the wire. Keep `TestPhase09PermissionMatrix` and `TestPhase09ContractInventory` green with the new routes (options, filters, lang).
(4) SPA stays green: update `admin/src/api/types.ts` aliases and any call site in `ListView.vue`, `useAuth.ts` and `useNavigation.ts` whose types changed so `npm --prefix admin run typecheck` passes with no casts to `any`; rebuild `boardwalk/dist` only if the built output changed.
(5) fonoteka `admin_phase10_controllers_test.go` implements TestPhase10Controllers (D-08: fields and columns are exactly the tracked YAML; no mock-only fields).
Regeneration step (end of task): steps (1) and (2) change the document and the converter output, so after the last annotation, documented-type or converter edit run `scripts/check-admin-openapi.sh` once more, commit the regenerated `admin/openapi/admin.json` and `admin/src/api/schema.d.ts` together with the step (4) SPA updates that consume them, and confirm `scripts/check-admin-openapi.sh --check` prints no diff; the task (and the plan) ends drift-clean.</action>
<verify>
<automated>go test ./cabana -run '^TestPhase10(FilterOptions|OpenAPIConformance)$|^TestPhase09(PermissionMatrix|ContractInventory)$' -count=1 -v &amp;&amp; scripts/check-admin-openapi.sh --check &amp;&amp; npm --prefix admin run typecheck &amp;&amp; scripts/check-admin-dist.sh &amp;&amp; (cd ../fonoteka.go &amp;&amp; go test ./plugins/golem15/fonoteka -run '^TestPhase10Controllers$' -count=1 -v) &amp;&amp; go vet ./... &amp;&amp; go test ./... &amp;&amp; (cd ../fonoteka.go &amp;&amp; go vet ./... &amp;&amp; go test ./...)</automated>
<fails_when>Any command exits non-zero; a named test is missing its "--- PASS" line or shows "no tests to run" or SKIP; either drift script prints a diff; vue-tsc reports an error.</fails_when>
</verify>
<acceptance_criteria>
- `python3 -c "import json;d=json.load(open('admin/openapi/admin.json'));s=json.dumps(d);assert 'cabana.SuccessEnvelope' not in s and '/{vendor}/{plugin}/{controller}/filters/{scope}/options' in d['paths'] and '/lang' in d['paths']"` exits 0.
- `grep -rn 'as any' admin/src` prints nothing.
- TestPhase10OpenAPIConformance covers every route in the admin inventory (the test fails when a route has no conformance case).
- Both repositories pass `go vet ./...` and `go test ./...`.
</acceptance_criteria>
<done>Filter bars can fetch model-backed choices, and every admin endpoint the SPA calls has a generated TypeScript type that is proven to match what the handler writes.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| SPA → options/filters endpoints | Untrusted search, paging and field/scope names select related rows |
| SPA → record save | Untrusted relation ids and keys become foreign keys and pivot rows |
| Anonymous browser → /lang | Unauthenticated callers read the string bundle |
| Plugin YAML/override FS → boot | Plugin-supplied copy and toolbar declarations shape every schema |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-10-09 | Elevation of Privilege | cabana relation save (IDOR via relation ids) | high | mitigate | Submitted ids are revalidated through the same RelationExtendOptionsQuery-scoped query inside the save transaction; unknown, out-of-scope and duplicate ids are 422 and roll back; TestPhase10RelationForgedID and TestPhase10AlbumRelations. |
| T-10-10 | Tampering | belongs-to mapping of protected foreign keys (mass assignment) | high | mitigate | D-26: a belongsTo whose FK is a protected fill key is readOnly, never written, and its options endpoint is 404; protectedFillKey and FormBeforeCreate unchanged; TestPhase10CollectionOwnerReadOnly. |
| T-10-11 | Information Disclosure | fields/{field}/options enumeration | medium | mitigate | Served through protect (controller permission before SQL), scoped by the hook, per_page capped at 100, 404 for non-relation and read-only fields (no user-email enumeration via owner); TestPhase10RelationOptions. |
| T-10-12 | Information Disclosure | public /lang bundle | low | mitigate | Bundle limited to the backend::lang prefix; TestPhase10Bundle asserts no other namespace appears. |
| T-10-13 | Tampering | messages and toolbar YAML | low | mitigate | Strict decoding with unknown-key rejection, custom toolbar unmarshal, boot-time key existence checks; TestPhase10Messages and TestPhase10Toolbar. |
| T-10-14 | Tampering | OpenAPI document versus handler output | medium | mitigate | TestPhase10OpenAPIConformance decodes every handler response into its documented type with unknown fields disallowed; check-admin-openapi.sh --check guards drift. |
| T-10-15 | Elevation of Privilege | filters/{scope}/options | medium | mitigate | protect() before the provider, scope names allow-listed against the compiled list filters, 404 otherwise; TestPhase10FilterOptions. |
| T-10-SC | Tampering | npm/Go dependencies | high | mitigate | No new npm or Go package; admin/ uses npm ci against the lockfile approved in Plan 10-01; swag stays pinned at v1.16.6 via go run. |
</threat_model>
<verification>
Run `go vet ./... && go test ./...` in summercms.go and `(cd ../fonoteka.go && go vet ./... && go test ./...)`, then `scripts/check-admin-openapi.sh --check`, `scripts/check-admin-dist.sh` and `npm --prefix admin run typecheck`. A non-zero exit, a skipped PostgreSQL Phase 10 test, or a printed diff fails the plan.
</verification>
<success_criteria>
- Relation choices, relation saves with labels, the read-only owner, messages, the toolbar, the string bundle and filter choices behave as specified and are covered by named tests.
- The admin OpenAPI document is fully typed and proven against the wire; the SPA still typechecks against it.
- The five fonoteka controllers serve exactly their tracked YAML through the prefix with complete Polish copy.
</success_criteria>
<output>
Create `.planning/phases/10-admin-vue-spa/10-02-SUMMARY.md` when done.
</output>

View File

@@ -0,0 +1,285 @@
---
phase: 10-admin-vue-spa
plan: 03
type: execute
wave: 3
depends_on: [10-02]
files_modified:
- admin/src/main.ts
- admin/src/app/router.ts
- admin/src/app/i18n.ts
- admin/src/app/winterUrl.ts
- admin/src/app/listQuery.ts
- admin/src/api/types.ts
- admin/src/state/useToasts.ts
- admin/src/state/useSettings.ts
- admin/src/views/ListView.vue
- admin/src/views/FormView.vue
- admin/src/views/SettingsIndexView.vue
- admin/src/views/SettingsFormView.vue
- admin/src/components/shell/AppShell.vue
- admin/src/components/shell/PluginRail.vue
- admin/src/components/list/DataTable.vue
- admin/src/components/list/ListToolbar.vue
- admin/src/components/list/FilterBar.vue
- admin/src/components/list/Pagination.vue
- admin/src/components/list/CellValue.vue
- admin/src/components/form/FormTabs.vue
- admin/src/components/form/FormGrid.vue
- admin/src/components/form/FormField.vue
- admin/src/components/form/FieldRenderer.vue
- admin/src/components/form/registry.ts
- admin/src/components/form/fields/TextField.vue
- admin/src/components/form/fields/TextareaField.vue
- admin/src/components/form/fields/NumberField.vue
- admin/src/components/form/fields/DropdownField.vue
- admin/src/components/form/fields/SwitchField.vue
- admin/src/components/form/fields/CheckboxField.vue
- admin/src/components/form/fields/RelationField.vue
- admin/src/components/form/fields/UnsupportedField.vue
- admin/src/components/ui/Button.vue
- admin/src/components/ui/Toast.vue
- admin/src/components/ui/ConfirmDialog.vue
- admin/tests/fixtures/lang.json
- admin/tests/fixtures/widgets.form-schema.json
- admin/tests/fixtures/widgets.record.json
- admin/tests/fixtures/widgets.list-schema.json
- admin/tests/fixtures/widgets.list.json
- admin/tests/fixtures/widgets.options.json
- admin/tests/fixtures/settings.json
- admin/tests/smoke/edit.smoke.test.ts
- admin/tests/smoke/list.smoke.test.ts
- admin/tests/smoke/form.smoke.test.ts
- admin/tests/smoke/settings.smoke.test.ts
- phrasebook/backend/lang/en/lang.yaml
- phrasebook/backend/lang/pl/lang.yaml
- boardwalk/dist/**
autonomous: true
requirements: [ADMIN-06]
estimate:
tokens: 130000
raw_tokens: 130000
tasks: 3
confidence: low
must_haves:
truths:
- "Per D-20/D-24, the SPA loads the backend::lang bundle before auth at startup, sets the document language from meta.locale, and its interpolation replaces :name, :Name and :NAME exactly like phrasebook.interpolate; plural messages pick the CLDR form with Intl.PluralRules(locale) and fall back to other."
- "Per D-10/D-18, clicking a list row opens {backend.uri}/{vendor}/{plugin}/{controller}/{id}; the form sends scalar values and relation ids keyed by field name, and a successful save shows the resolved saved message as a toast (role=status, auto-dismiss about 4 s)."
- "Per D-09, a 422 response's error.details field-to-messages map puts each message under its field (aria-invalid, aria-describedby), shows the banner with the plural invalid-field count, focuses the first invalid field, marks tabs holding invalid fields with a count badge, and clears a field's error when it changes."
- "Per D-05, the field renderer registry maps text, textarea, number, checkbox, switch, dropdown and relation to components, and any unregistered type renders the UnsupportedField box with the type name in DM Mono instead of breaking the form."
- "Per D-14/D-13, the list renders toolbarButtons in declared order (create in the heading row, delete in the toolbar), delete is disabled without a selection and confirms with the plural deleteConfirm message before POST bulk-delete, and the heading subtitle is the plural recordCount message for the list total."
- "Per D-22/D-27, the filter bar renders switch, daterange and model-backed scope filters from schema.filters, loads scope choices from the filters options endpoint, and drives filter[name] in the URL; the list's search (300 ms debounce), sort (asc, desc, none with aria-sort), page, per_page and filters live in the URL query and a change of any of them resets the selection."
- "Per D-17/D-18/D-26, a single relation field searches the fields options endpoint and shows emptyOption first; a multiple relation field shows removable chips in order; a readOnly relation field shows only its label from meta.labels."
- "Per D-21, the Ustawienia rail item appears when the settings list is non-empty, lists every permitted settings page, and renders each one through the same FormGrid and field registry against the settings schema and GET/PUT endpoints."
- "Per D-08/D-12, the SPA renders exactly the columns and fields the server schema declares; a text column renders its value as plain text (no per-value icon pills); empty values render a muted dash."
- "An empty list shows the empty message; an empty search shows the search-x state with the term and a clear-search button; loading shows eight skeleton rows while the toolbar and footer stay mounted."
artifacts:
- path: "admin/src/views/FormView.vue"
provides: "Schema-driven create/update form with tabs, 422 mapping, footer actions, dirty guard and redirect mapping"
- path: "admin/src/components/form/registry.ts"
provides: "D-05 field renderer registry with UnsupportedField fallback"
- path: "admin/src/components/list/DataTable.vue"
provides: "Schema columns, selection, sort, states"
- path: "admin/src/components/list/FilterBar.vue"
provides: "Switch, daterange and scope filters driving filter[name]"
- path: "admin/src/app/i18n.ts"
provides: "Bundle loading, t, plural selection and phrasebook-compatible interpolation"
- path: "admin/src/views/SettingsFormView.vue"
provides: "Settings screen through the shared form renderer"
key_links:
- from: "admin/src/components/form/fields/RelationField.vue"
to: "GET /{vendor}/{plugin}/{controller}/fields/{field}/options"
via: "typed openapi-fetch call with search and page"
pattern: "fields/{field}/options"
- from: "admin/src/components/list/FilterBar.vue"
to: "GET /{vendor}/{plugin}/{controller}/filters/{scope}/options"
via: "typed openapi-fetch call"
pattern: "filters/{scope}/options"
- from: "admin/src/main.ts"
to: "GET /lang"
via: "loadStrings before /auth/me"
pattern: "/lang"
- from: "admin/src/views/FormView.vue"
to: "admin/src/app/winterUrl.ts"
via: "redirects and recordUrl mapped onto D-10 routes"
pattern: "mapWinterUrl"
prohibitions:
- "[flagged-unverified] Plugin-supplied labels, messages and record values must be rendered as text only, never as raw HTML."
- "[flagged-unverified] The SPA must not declare its own TypeScript shapes for API payloads; every payload type is an alias of a generated schema type or a string-keyed record read through its schema."
- "[flagged-unverified] The SPA must not hide or add navigation, toolbar actions or fields on its own; it renders only what the server schema and navigation return."
- "[flagged-unverified] Winter recordUrl and redirect strings must not be used verbatim as SPA routes."
---
## 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.
<objective>
Turn the tracer's read-only list into the full list and form experience for any controller: open a record, edit every built-in field type, save with 422 feedback and toasts, create and delete, search, sort, filter, page and bulk-delete, and manage settings pages through the same renderer. All five fonoteka controllers become usable because they only use built-in field and column types.
Purpose: Success criterion 2 (each of the five controllers renders a working list and form generated from its JSON schema) is met by generic components driven by the Plan 10-02 contract. Decisions implemented: D-05, D-06, D-08, D-09, D-10, D-12, D-13, D-14, D-17, D-18, D-20, D-21, D-22, D-24, D-26, D-27; D-28 fixes this plan's scope.
Output: list, form, filter and settings components and views, i18n bundle loading, smoke tests with neutral fixtures, rebuilt `boardwalk/dist`.
Repo: summercms.go only (admin/, phrasebook backend lang for new SPA keys, boardwalk/dist). Commit code separately from planning docs; never add co-author tags.
</objective>
<execution_context>
@~/.claude/gsd-core/workflows/execute-plan.md
@~/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.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/10-admin-vue-spa/10-01-SUMMARY.md
@.planning/phases/10-admin-vue-spa/10-02-SUMMARY.md
@admin/src/api/schema.d.ts
@admin/src/api/client.ts
@admin/src/views/ListView.vue
@phrasebook/translator.go
<interfaces>
From Plans 10-01/10-02 (read the generated `admin/src/api/schema.d.ts` for exact shapes; never re-declare them):
- Client: `api.GET/POST/PUT/DELETE(path, {params: {path, query}, body})` typed by `paths`; base URL from runtime meta; CSRF header and refresh are handled in `src/api/client.ts`.
- Routes (prefix-relative): `/lang`, `/auth/me`, `/navigation`, `/settings`, `/settings/{code}/schema`, `/settings/{code}`, `/{vendor}/{plugin}/{controller}/schema/list|form`, `/{vendor}/{plugin}/{controller}`, `/{vendor}/{plugin}/{controller}/{id}`, `/{vendor}/{plugin}/{controller}/bulk-delete`, `/{vendor}/{plugin}/{controller}/fields/{field}/options`, `/{vendor}/{plugin}/{controller}/filters/{scope}/options`.
- Schemas: ListSchema (columns, filters, toolbarButtons, perPageOptions, recordsPerPage, recordUrl, messages), FormView (fields with type, span, tab, context, required, comment, options, emptyOption, nameFrom, multiple, readOnly; messages; redirects), record envelope data plus meta.labels, D-10 error envelope with details field to messages, list meta page/per_page/total/last_page.
- `messages` values are CLDR form maps (key to text); plain strings arrive as {other: text}.
- Filter wire values (Phase 9 query.go): switch sends the raw JSON of the option value (JSON.stringify of the scalar), daterange sends YYYY-MM-DD..YYYY-MM-DD, scope sends the option value.
</interfaces>
</context>
## Artifacts this phase produces
- `admin/src/app/i18n.ts`: `loadStrings()`, `t(key, params)`, `tc(key, count, params)`, `message(forms, count, params)`, `interpolate(text, params)`
- `admin/src/app/winterUrl.ts`: `mapWinterUrl(url, controllerId, id)`; `admin/src/app/listQuery.ts`: `parseListQuery`, `toListQuery`
- `admin/src/state/useToasts.ts` (queue), `admin/src/state/useSettings.ts`
- Views: `FormView.vue`, `SettingsIndexView.vue`, `SettingsFormView.vue`; routes `/:vendor/:plugin/:controller/create`, `/:vendor/:plugin/:controller/:id(\\d+)`, `/settings`, `/settings/:code`
- Components: `DataTable`, `ListToolbar`, `FilterBar`, `Pagination`, `CellValue`, `FormTabs`, `FormGrid`, `FormField`, `FieldRenderer`, `registry.ts` (`rendererFor(type)`), fields `TextField`, `TextareaField`, `NumberField`, `DropdownField`, `SwitchField`, `CheckboxField`, `RelationField`, `UnsupportedField`, ui `Button`, `Toast`, `ConfirmDialog`
- New `backend::lang` keys for every SPA string introduced here (pl and en)
- Smoke tests `tests/smoke/{edit,list,form,settings}.smoke.test.ts` with neutral `acme.demo.widgets` fixtures
<tasks>
<task type="tracer">
<name>Task 1: An admin opens a record from a list, edits it and saves it with toast and 422 feedback</name>
<files>admin/src/main.ts, admin/src/app/router.ts, admin/src/app/i18n.ts, admin/src/app/winterUrl.ts, admin/src/api/types.ts, admin/src/state/useToasts.ts, admin/src/views/ListView.vue, admin/src/views/FormView.vue, admin/src/components/shell/AppShell.vue, admin/src/components/list/DataTable.vue, admin/src/components/form/FormGrid.vue, admin/src/components/form/FormField.vue, admin/src/components/form/FieldRenderer.vue, admin/src/components/form/registry.ts, admin/src/components/form/fields/TextField.vue, admin/src/components/form/fields/TextareaField.vue, admin/src/components/form/fields/NumberField.vue, admin/src/components/form/fields/DropdownField.vue, admin/src/components/form/fields/UnsupportedField.vue, admin/src/components/ui/Button.vue, admin/src/components/ui/Toast.vue, admin/tests/fixtures/lang.json, admin/tests/fixtures/widgets.form-schema.json, admin/tests/fixtures/widgets.record.json, admin/tests/smoke/edit.smoke.test.ts, phrasebook/backend/lang/en/lang.yaml, phrasebook/backend/lang/pl/lang.yaml, boardwalk/dist/**</files>
<read_first>admin/src/api/schema.d.ts, admin/src/api/client.ts, admin/src/api/types.ts, admin/src/app/router.ts, admin/src/app/i18n.ts, admin/src/views/ListView.vue, admin/src/components/list/DataTable.vue, admin/src/main.ts, phrasebook/translator.go (interpolate), phrasebook/backend/lang/pl/lang.yaml, .planning/phases/10-admin-vue-spa/design/README.md (screen 4, Interactions, State Management), .planning/phases/10-admin-vue-spa/10-RESEARCH.md (Patterns 7 and 8)</read_first>
<action>Build the thinnest edit path through every SPA layer, using only generated API types (aliases in `src/api/types.ts`) and text interpolation.
(1) Strings, per D-20/D-24: `src/app/i18n.ts` gains `loadStrings()` (GET `/lang`, store the key to forms map and `meta.locale`, set `document.documentElement.lang`), `t(key, params)` (forms.other, else the key), `tc(key, count, params)` and `message(forms, count, params)` (select with `new Intl.PluralRules(locale).select(count)`, fall back to `other`, add `count` to params), and `interpolate(text, params)` that mirrors `phrasebook.interpolate`: for each param build `:Name` (first rune upper-cased value), `:NAME` (upper-cased value) and `:name` (value), and replace longest placeholders first. `src/main.ts` boots `loadStrings()` then `/auth/me` then `/navigation`, then mounts.
(2) Routes and URL mapping, per D-10: add `/:vendor/:plugin/:controller/create` and `/:vendor/:plugin/:controller/:id(\\d+)` to the router. `src/app/winterUrl.ts` exports `mapWinterUrl(url, controllerId, id)`: strip the leading `vendor/plugin/controller` path of the controller, then an empty remainder maps to the list route, `create` to the create route, `update/:id` to the record route with the id substituted, and anything else to the list route (research Gap 8). `DataTable` rows link to the record route when the schema has a `recordUrl`; the first column is weight 600.
(3) Form, per D-05/D-09/D-18: `FormView.vue` loads `schema/form` and, in update mode, the record (data plus meta.labels); it shows fields whose `context` allows the mode. `FormGrid.vue` maps span left to column 1, right to column 2, full to the full row, auto and row to the next free slot, with one column below 600px, and the 22px by 24px gaps from the design. `FormField.vue` renders the label (600 weight, an aria-hidden red asterisk plus `aria-required` on the control when `required`), the comment, the control and the error line (13px, danger colour, `circle-alert` 14px, linked by `aria-describedby`). `registry.ts` exports `rendererFor(type)` over a Map of the built-ins registered so far (text, textarea, number, dropdown); every other type returns `UnsupportedField.vue`, the design's dashed box with the `puzzle` icon and `t('backend::lang.form.unsupported_field', {type})` where the type is rendered in DM Mono. Text, textarea (rows from `size`), number and dropdown (schema options, `emptyOption` first and muted) are v-model controls at 44px height. Saving POSTs (create) or PUTs (update) the values keyed by field name; relation fields send ids (wired by Task 3). A 200/201 shows a toast with the form's `saved` message (`:name` is the value of the first text field in schema order) and, after a create, navigates to `mapWinterUrl(redirects.create.redirect, ...)` for the new id. A 422 reads `error.details` (field to messages): each message renders under its field with `aria-invalid="true"`, a top banner shows the plural invalid-field count, the first invalid field receives focus, and editing a field clears its error. Other errors show a danger toast with the envelope message.
(4) Toasts: `src/state/useToasts.ts` is a module-level queue; `Toast.vue` (mounted by `AppShell.vue`) is bottom-centre 88px from the bottom, navy background, 26px yellow check circle, close button `aria-label` from `t`, `role="status"`, auto-dismiss after about 4 seconds.
(5) Add every new SPA string key to `phrasebook/backend/lang/{pl,en}/lang.yaml` (design copy in Polish), keep `TestPhase10SPAKeysResolve` green, add neutral fixtures and `tests/smoke/edit.smoke.test.ts` (list row opens the record route; editing and saving PUTs the typed body and shows the saved toast; a 422 fixture maps messages to fields, focuses the first invalid one and clears on input; an unknown `colorpicker` field renders the unsupported box; interpolate matches phrasebook for `:name`, `:Name`, `:NAME`), then rebuild `boardwalk/dist`.</action>
<verify>
<automated>npm --prefix admin run typecheck &amp;&amp; npm --prefix admin test -- tests/smoke/edit.smoke.test.ts &amp;&amp; go test ./phrasebook -run '^TestPhase10SPAKeysResolve$' -count=1 -v &amp;&amp; scripts/check-admin-dist.sh</automated>
<fails_when>Any command exits non-zero; vitest prints "No test files found" or any failed test; the go test output lacks "--- PASS: TestPhase10SPAKeysResolve" or shows "no tests to run"; check-admin-dist.sh prints a diff.</fails_when>
</verify>
<acceptance_criteria>
- The edit smoke test passes and covers row navigation, typed PUT, saved toast, 422 mapping with focus and clearing, and the unsupported box.
- `grep -rn 'v-html' admin/src` prints nothing.
- `grep -rln 'fetch(' admin/src | grep -v 'admin/src/api/client.ts'` prints nothing.
- `scripts/check-admin-dist.sh` exits 0.
</acceptance_criteria>
<done>From any controller list an admin can open a record, change text, textarea, number and dropdown fields, save, and see either the saved toast or per-field validation messages.</done>
</task>
<task type="auto">
<name>Task 2: An admin searches, sorts, filters, pages and bulk-deletes any list</name>
<files>admin/src/app/listQuery.ts, admin/src/views/ListView.vue, admin/src/components/list/DataTable.vue, admin/src/components/list/ListToolbar.vue, admin/src/components/list/FilterBar.vue, admin/src/components/list/Pagination.vue, admin/src/components/list/CellValue.vue, admin/src/components/ui/ConfirmDialog.vue, admin/tests/fixtures/widgets.list-schema.json, admin/tests/fixtures/widgets.list.json, admin/tests/fixtures/widgets.options.json, admin/tests/smoke/list.smoke.test.ts, phrasebook/backend/lang/en/lang.yaml, phrasebook/backend/lang/pl/lang.yaml, boardwalk/dist/**</files>
<read_first>admin/src/views/ListView.vue, admin/src/components/list/DataTable.vue, admin/src/app/i18n.ts, admin/src/api/types.ts, admin/src/api/schema.d.ts, cabana/query.go (normalizePage, pageSizeAllowed, switchArgument, parseDateRange, scopeArgument), .planning/phases/10-admin-vue-spa/design/README.md (screen 3, Interactions)</read_first>
<action>(1) URL state: `src/app/listQuery.ts` parses and serializes `search`, `sort`, `dir`, `page`, `per_page` and `filter[<name>]` between the route query and a typed list input; ListView reads the list from the URL and every change is a router replace. Search is debounced 300 ms and resets page to 1; changing search, sort, page, per_page or a filter clears the selection.
(2) DataTable, per the design screen 3 and D-12: a 52px checkbox column when `showCheckboxes` (header checkbox tri-state: empty, mixed with the `minus` icon, checked; it selects the current page), sortable headers cycle asc, desc, none with `arrow-up`/`arrow-down` 14px, `aria-sort` and the text colour on the sorted header, 54px rows with the `sel` background when selected, a sticky subtle header, horizontal scroll inside the card. `CellValue.vue` renders by column type: default text (a muted dash for null or empty; arrays comma-joined), `datetime` as `YYYY-MM-DD HH:mm` at 13px muted with tabular numbers, `switch` as the green Tak pill with `arrow-up-right` or the muted outline Nie pill (strings from `backend::lang.list.column_switch_true/false`); no per-value icon pills. Loading shows eight skeleton rows of the same height with the toolbar and footer mounted. An empty list shows the list's `empty` message; an empty search shows the 56px `search-x` circle, the `emptySearch` title, the `emptySearchHint` text with `:term`, and an outline clear-search button.
(3) Actions, per D-13/D-14: the heading row shows the localized title and the plural `recordCount` message for meta.total; `toolbarButtons` render in declared order, with `create` as the primary button (`plus`) in the heading row linking to the create route and `delete` in the toolbar. With no selection delete is a disabled outline button (native `disabled`); with a selection the `selected` pill (`check` icon, sel background) appears and delete is a danger outline button (`trash-2`). Delete opens `ConfirmDialog.vue` (Reka Dialog, alert dialog role) with the plural `deleteConfirm` message, then POSTs `bulk-delete` with the ids, shows the plural `deleted` toast and reloads; a 409 shows a danger toast.
(4) FilterBar, per D-22/D-27: rendered only when `schema.filters` is non-empty. A switch filter offers an "all" choice plus its options (or true/false values) and sends `JSON.stringify(option.value)`; a daterange filter has two date inputs and sends `from..to`; a scope filter loads its choices from `/filters/{scope}/options` and sends the chosen value. Each writes `filter[name]` to the URL and resets the page.
(5) Pagination: range text `:from–:to z :total` (en dash) from `t`, the plural results message for zero rows with no pager, the per-page select over `schema.perPageOptions` (falling back to `[recordsPerPage]`, hidden when there is one choice; research Gap 7), page buttons with ellipsis, the current page with sel background, weight 700 and `aria-current`, and previous/next buttons disabled at the ends (34px, radius 8).
(6) Add the new keys to both backend lang files, a neutral filters fixture (one switch, one daterange, one scope) and `tests/smoke/list.smoke.test.ts` (sort cycle and aria-sort, debounced search resets page, tri-state header selection, disabled and enabled delete with plural confirm and bulk-delete body, each filter shape writes the right `filter[...]` value, per-page hidden for one option, empty and empty-search states, skeleton rows), then rebuild `boardwalk/dist`.</action>
<verify>
<automated>npm --prefix admin run typecheck &amp;&amp; npm --prefix admin test -- tests/smoke/list.smoke.test.ts &amp;&amp; go test ./phrasebook -run '^TestPhase10SPAKeysResolve$' -count=1 -v &amp;&amp; scripts/check-admin-dist.sh</automated>
<fails_when>Any command exits non-zero; vitest prints "No test files found" or any failed test; the go test output lacks "--- PASS: TestPhase10SPAKeysResolve" or shows "no tests to run"; check-admin-dist.sh prints a diff.</fails_when>
</verify>
<acceptance_criteria>
- The list smoke test passes and covers every behaviour named in item (6).
- `grep -c 'JSON.stringify' admin/src/components/list/FilterBar.vue` prints at least 1 and `grep -c 'filters/{scope}/options' admin/src/components/list/FilterBar.vue` prints at least 1.
- `grep -rn 'v-html' admin/src` prints nothing.
</acceptance_criteria>
<done>Every controller list supports search, sort, filters, paging, selection and confirmed bulk delete with the controller's own copy, and its state survives a reload through the URL.</done>
</task>
<task type="auto">
<name>Task 3: An admin works through tabs, toggles and relation fields, creates and deletes records, and edits settings pages</name>
<files>admin/src/app/router.ts, admin/src/state/useSettings.ts, admin/src/views/FormView.vue, admin/src/views/SettingsIndexView.vue, admin/src/views/SettingsFormView.vue, admin/src/components/shell/PluginRail.vue, admin/src/components/form/FormTabs.vue, admin/src/components/form/registry.ts, admin/src/components/form/fields/SwitchField.vue, admin/src/components/form/fields/CheckboxField.vue, admin/src/components/form/fields/RelationField.vue, admin/src/components/ui/ConfirmDialog.vue, admin/tests/fixtures/widgets.form-schema.json, admin/tests/fixtures/widgets.options.json, admin/tests/fixtures/settings.json, admin/tests/smoke/form.smoke.test.ts, admin/tests/smoke/settings.smoke.test.ts, phrasebook/backend/lang/en/lang.yaml, phrasebook/backend/lang/pl/lang.yaml, boardwalk/dist/**</files>
<read_first>admin/src/views/FormView.vue, admin/src/components/form/registry.ts, admin/src/components/form/FormGrid.vue, admin/src/components/shell/PluginRail.vue, admin/src/api/schema.d.ts, cabana/settings.go, cabana/form_schema.go (context handling), .planning/phases/10-admin-vue-spa/design/README.md (screen 4 tabs, toggle cards, sticky footer, Interactions)</read_first>
<action>(1) Tabs: `FormTabs.vue` groups fields by `tab`; fields without a tab go to a first tab labelled `t('backend::lang.form.tab_default')`; tabs render only when at least one field has a tab, as the design's segmented control (`role="tablist"`/`"tab"`, `aria-selected`, 34px pills) with a count badge on any tab holding invalid fields after a 422.
(2) Toggles and relations, per D-05/D-17/D-18/D-26: register `switch`, `checkbox` and `relation` in `registry.ts`. `SwitchField.vue` and `CheckboxField.vue` are the design's toggle cards (label as title, comment as helper text; a 44 by 26 `role="switch"` control or a 20px checkbox). `RelationField.vue` has three modes from the schema: `readOnly` shows only the label from `meta.labels` (no control); single shows a searchable select that queries `/fields/{field}/options` (300 ms debounce, `per_page` 20, more on scroll or a next-page action) with `emptyOption` first and muted, sending the id or null; `multiple` shows chips (30px pill, 22px initials avatar in the primary colour, name at 600 weight, a remove button whose `aria-label` is `t('backend::lang.form.remove_item', {name})`) in order, plus an inline input that opens the same searchable listbox and appends the chosen id. Initial labels come from `meta.labels`. `relation-manager` stays unregistered until Plan 10-04 and so renders the unsupported box.
(3) Footer and lifecycle: a sticky footer with, on the left, a danger outline Usuń (`trash-2`, update mode only) that confirms with the form's `deleteConfirm` message then DELETEs, toasts `deleted` and returns to the list; on the right Anuluj (ghost, returns to the list), Zapisz i zamknij (outline, saves then goes to `mapWinterUrl(redirects.<mode>.redirectClose, ...)` and shows the toast there) and Zapisz (primary, saves and stays; after create it goes to the mapped `create.redirect`). The header has a 40px back button (`arrow-left`, labelled from `t`), the record title (first text field value, or the form's `create` message in create mode) and the `update` message as subtitle. A dirty form asks for confirmation (ConfirmDialog) before Anuluj, the back button or any route leave, and registers a `beforeunload` guard while dirty.
(4) Settings, per D-21: `src/state/useSettings.ts` loads `/settings`; `PluginRail.vue` shows the Ustawienia item (`settings` icon, pinned to the bottom) only when that list is non-empty; `/settings` (`SettingsIndexView.vue`) lists entries grouped by category with icon, label and description; `/settings/:code` (`SettingsFormView.vue`) loads `/settings/{code}/schema` and `/settings/{code}`, renders the same FormGrid and registry, PUTs the values, toasts the default saved message and maps a 422 like the record form.
(5) Add the new keys to both backend lang files, extend the neutral form fixture with tabs, a switch, a checkbox, single, multiple and read-only relations and an unknown type, add `tests/smoke/form.smoke.test.ts` (tab badges after 422, toggle cards, each relation mode including option search and ordered ids in the body, context filtering between create and update, delete confirm, dirty guard, create redirect mapping) and `tests/smoke/settings.smoke.test.ts` (rail item visibility, index listing, settings save and 422), then rebuild `boardwalk/dist`.</action>
<verify>
<automated>npm --prefix admin run typecheck &amp;&amp; npm --prefix admin test -- tests/smoke &amp;&amp; go test ./phrasebook -run '^TestPhase10SPAKeysResolve$' -count=1 -v &amp;&amp; scripts/check-admin-dist.sh &amp;&amp; (cd ../fonoteka.go &amp;&amp; go test ./plugins/golem15/fonoteka -run '^TestPhase10Controllers$' -count=1 -v)</automated>
<fails_when>Any command exits non-zero; vitest prints "No test files found" or any failed test; a go test output lacks its "--- PASS" line or shows "no tests to run" or SKIP; check-admin-dist.sh prints a diff.</fails_when>
<human-check>
<test>Run the fonoteka binary (`summer serve` in ../fonoteka.go with a local database and SUMMER_ADMIN__JWT__SECRET set), open http://localhost:8080/plytadmin, log in as a developer-role admin and open Albumy, Artyści, Kolekcje, Gatunki, Style and Ustawienia.</test>
<expected>Each list shows its YAML columns with Polish copy; each form opens, the album genre dropdown and artist chips work, Kolekcje shows the owner as read-only text, saving shows the toast, and an empty required name shows the field error and banner.</expected>
<why_human>No browser e2e in Phase 10 (D-23); real rendering of the five controllers against the Go backend can only be judged in a browser.</why_human>
</human-check>
</verify>
<acceptance_criteria>
- Form and settings smoke tests pass; every smoke test under `tests/smoke` passes.
- `grep -c "'switch'\|'checkbox'\|'relation'" admin/src/components/form/registry.ts` prints at least 3 and `grep -c "'relation-manager'" admin/src/components/form/registry.ts` prints 0.
- `TestPhase10Controllers` passes, so every field type the five controllers serve is registered except `relation-manager` (Plan 10-04).
</acceptance_criteria>
<done>All five controllers' forms work with tabs, toggles and relation fields, records can be created, updated and deleted safely, and settings pages are editable through the same renderer.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Admin API responses → DOM | Plugin-supplied labels, messages and record values are rendered in the admin origin |
| URL query → list requests | User-editable query parameters become list filters and sorting |
| Winter redirect strings → router | Plugin YAML strings choose navigation targets |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-10-16 | Tampering | SPA rendering of plugin labels, messages and record values (XSS) | high | mitigate | Text interpolation only; no raw-HTML directive anywhere in admin/src (acceptance grep); interpolate replaces placeholders in plain strings, never markup. |
| T-10-18 | Elevation of Privilege | client-side hiding of actions and fields | low | accept | The server enforces permissions, toolbar actions, writable fields and relation scope (Plans 10-01/10-02); the SPA renders only what it receives and never adds entries, so client manipulation gains nothing. |
| T-10-19 | Information Disclosure | list state (search terms, filters) in the URL | low | accept | Admin-only, same-origin, Referrer-Policy same-origin and noindex from Plan 10-01; search terms are not secrets. |
| T-10-20 | Tampering | Winter redirect and recordUrl strings used for navigation | low | mitigate | mapWinterUrl only produces routes under the current controller (list, create, record); unknown shapes fall back to the list; smoke test covers mapping. |
| T-10-SC | Tampering | npm dependencies | high | mitigate | No new package; installs use npm ci against the lockfile approved in Plan 10-01. |
</threat_model>
<verification>
Run `npm --prefix admin run typecheck && npm --prefix admin test`, `go test ./phrasebook -count=1`, `scripts/check-admin-dist.sh`, `scripts/check-admin-openapi.sh --check`, and `go vet ./... && go test ./...` in summercms.go. A non-zero exit, a failed or missing smoke test, or a printed diff fails the plan.
</verification>
<success_criteria>
- Lists and forms for any controller are generated from the server schema with every built-in field and column type, 422 mapping, toasts, toolbar actions, filters, pagination and URL state (SC-2).
- Settings pages render through the same form renderer (D-21).
- No raw-HTML rendering, no hand-written API types, no network calls outside the typed client.
</success_criteria>
<output>
Create `.planning/phases/10-admin-vue-spa/10-03-SUMMARY.md` when done.
</output>

View File

@@ -0,0 +1,220 @@
---
phase: 10-admin-vue-spa
plan: 04
type: execute
wave: 4
depends_on: [10-03]
files_modified:
- admin/src/components/relation/RelationManager.vue
- admin/src/components/relation/RelationPickerModal.vue
- admin/src/components/form/registry.ts
- admin/src/components/form/FormTabs.vue
- admin/src/views/FormView.vue
- admin/src/components/list/DataTable.vue
- admin/src/components/shell/AppShell.vue
- admin/src/components/shell/PluginRail.vue
- admin/src/components/shell/SectionPanel.vue
- admin/src/components/shell/SectionFlyout.vue
- admin/src/components/shell/UserMenu.vue
- admin/src/components/shell/Breadcrumbs.vue
- admin/src/components/ui/Toast.vue
- admin/src/state/useSidebar.ts
- admin/src/state/useAuth.ts
- admin/src/state/useToasts.ts
- admin/src/app/theme.ts
- admin/src/main.ts
- admin/src/styles/main.css
- admin/tests/fixtures/widgets.relation-schema.json
- admin/tests/fixtures/widgets.relation-linked.json
- admin/tests/fixtures/widgets.relation-candidates.json
- admin/tests/smoke/relation.smoke.test.ts
- admin/tests/smoke/shell.smoke.test.ts
- phrasebook/backend/lang/en/lang.yaml
- phrasebook/backend/lang/pl/lang.yaml
- boardwalk/dist/**
autonomous: true
requirements: [ADMIN-06]
estimate:
tokens: 80000
raw_tokens: 80000
tasks: 2
confidence: low
must_haves:
truths:
- "Per D-05/D-06 and success criterion 3, a relation-manager field renders only when editing an existing record (never on create), inside its tab, and lists the linked rows from GET /{id}/relations/{name} with the relation schema's view columns, search (300 ms debounce) and row selection."
- "In the RelationPickerModal (role=dialog, aria-modal, focus trapped, Esc closes, focus returns to the trigger), the admin searches candidates from GET /{id}/relations/{name}/candidates five per page with the owner excluded by the server, selects several across pages, and Dodaj (N) is disabled at N = 0; confirming POSTs link with the selected ids, refreshes the linked list and toasts the plural linked message."
- "Unlinking selected rows asks for confirmation with the plural unlinkConfirm message, POSTs unlink with the ids, refreshes and toasts the plural unlinked message; toolbar buttons follow the relation schema's view.toolbarButtons."
- "Per D-06/D-10, below about 1100px or after the admin collapses it, the section panel hides and only the rail remains; the manual choice persists in localStorage; hovering or focusing a rail item, or pressing Enter or ArrowDown on it, opens the SectionFlyout (role=menu) that closes on Esc or about 200 ms after mouse-leave and returns focus to the rail item."
- "The UserMenu shows initials, name and the role name from /auth/me (name and role hidden at tablet width) and a Wyloguj item that calls /auth/logout and always ends on the login screen; breadcrumbs show plugin, controller and record labels."
- "Dark mode follows prefers-color-scheme by toggling the .dark class on the document element, using the design's dark tokens; the sidebar stays dark in both modes."
- statement: "[flagged assumption A6] Dark mode follows the system preference only; the design has no toggle control."
verification: backstop
artifacts:
- path: "admin/src/components/relation/RelationManager.vue"
provides: "Linked list, search, selection, link and unlink flows for relation-manager fields"
- path: "admin/src/components/relation/RelationPickerModal.vue"
provides: "Candidate search, five-per-page paging, multi-select and Dodaj (N)"
- path: "admin/src/components/shell/SectionFlyout.vue"
provides: "Collapsed-rail flyout menu"
- path: "admin/src/components/shell/UserMenu.vue"
provides: "User menu with role and logout"
- path: "admin/src/state/useSidebar.ts"
provides: "Collapsed state from viewport and persisted manual choice"
key_links:
- from: "admin/src/components/relation/RelationPickerModal.vue"
to: "POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/link"
via: "typed openapi-fetch call with {ids}"
pattern: "relations/{name}/link"
- from: "admin/src/components/form/registry.ts"
to: "admin/src/components/relation/RelationManager.vue"
via: "relation-manager type registration"
pattern: "relation-manager"
- from: "admin/src/components/shell/UserMenu.vue"
to: "POST /auth/logout"
via: "useAuth.logout clears state and routes to login"
pattern: "auth/logout"
prohibitions:
- "[flagged-unverified] The SPA must not filter relation candidates itself (for example hiding the owner); exclusion and scoping come only from the server's candidates endpoint."
- "[flagged-unverified] localStorage must hold only the sidebar preference; no token, profile or record data."
---
## 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.
<objective>
Complete the admin experience: the relation manager with its picker modal (Collections editors: search, link, unlink) and the shell polish from the design (collapsible panel with flyout below 1100px, user menu with role and logout, breadcrumbs, dark mode, toast polish).
Purpose: Success criterion 3 (the Collections relation manager searches, links and unlinks an editor) plus the remaining D-06 fidelity items. Decisions implemented: D-05 (relation-manager renderer), D-06, D-10 (active rail and flyout), D-11 (icons already mapped in Plan 10-01 are reused); D-28 fixes this plan's scope.
Output: relation components, shell components, sidebar/theme state, smoke tests, rebuilt `boardwalk/dist`.
Repo: summercms.go only. Commit code separately from planning docs; never add co-author tags.
</objective>
<execution_context>
@~/.claude/gsd-core/workflows/execute-plan.md
@~/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/STATE.md
@.planning/phases/10-admin-vue-spa/10-CONTEXT.md
@.planning/phases/10-admin-vue-spa/design/README.md
@.planning/phases/10-admin-vue-spa/10-03-SUMMARY.md
@admin/src/api/schema.d.ts
@admin/src/views/FormView.vue
@admin/src/components/form/registry.ts
@admin/src/components/list/DataTable.vue
@admin/src/components/shell/AppShell.vue
@cabana/relation.go
<interfaces>
Relation API (Phase 9, prefix-relative, typed in schema.d.ts): `GET /{vendor}/{plugin}/{controller}/schema/relation/{name}` (label, view{list.columns, toolbarButtons, showSearch}, manage{...}, messages), `GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}` and `.../candidates` (list contract: search, sort, dir, page, per_page; ListEnvelope of records), `POST .../link` and `.../unlink` with `{ids}` returning `{linked}` or `{removed}`. Relation messages keys from Plan 10-02: link, linkHint, candidateSearch, linked, unlinkSelected, unlinkConfirm, unlinked, empty.
Auth: `POST /auth/logout` (cookie plus X-Requested-With from the client middleware), `/auth/me` profile with role {id, code, name}.
</interfaces>
</context>
## Artifacts this phase produces
- `RelationManager.vue`, `RelationPickerModal.vue`; `relation-manager` registered in `registry.ts`
- `SectionFlyout.vue`, `UserMenu.vue`, `Breadcrumbs.vue`; updated `AppShell`, `PluginRail`, `SectionPanel`, `Toast`
- `useSidebar()` (viewport below 1100px via matchMedia plus persisted manual flag, localStorage key `summer-admin.sidebar`), `useAuth().logout()`, `app/theme.ts` (`applyColorScheme()`)
- New `backend::lang` keys for shell and relation strings; smoke tests `tests/smoke/relation.smoke.test.ts`, `tests/smoke/shell.smoke.test.ts`
<tasks>
<task type="tracer">
<name>Task 1: An admin searches, links and unlinks editors on a Collection through the relation manager</name>
<files>admin/src/components/relation/RelationManager.vue, admin/src/components/relation/RelationPickerModal.vue, admin/src/components/form/registry.ts, admin/src/components/form/FormTabs.vue, admin/src/views/FormView.vue, admin/src/components/list/DataTable.vue, admin/tests/fixtures/widgets.relation-schema.json, admin/tests/fixtures/widgets.relation-linked.json, admin/tests/fixtures/widgets.relation-candidates.json, admin/tests/smoke/relation.smoke.test.ts, phrasebook/backend/lang/en/lang.yaml, phrasebook/backend/lang/pl/lang.yaml, boardwalk/dist/**</files>
<read_first>admin/src/views/FormView.vue, admin/src/components/form/registry.ts, admin/src/components/form/FormTabs.vue, admin/src/components/list/DataTable.vue, admin/src/components/ui/ConfirmDialog.vue, admin/src/app/i18n.ts, admin/src/api/schema.d.ts, cabana/relation.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/collections/config_relation.yaml, .planning/phases/10-admin-vue-spa/design/README.md (screen 5)</read_first>
<action>Per D-05/D-06, register `relation-manager` in `registry.ts` with `RelationManager.vue`. FormView passes the record id and mode; the field renders only in update mode (FormTabs never shows a tab whose only fields are relation managers on create), always full width.
`RelationManager.vue`: load `schema/relation/{name}` once (label, view columns, `view.toolbarButtons`, `view.showSearch`, messages). Section header: the relation label at 17px/700 with the relation-manager field's `comment` as helper text when the YAML declares one, a 220px by 38px search input (300 ms debounce, placeholder from the list search prompt default), and the toolbar buttons in declared order at 38px height: `link` as the primary `user-plus` button labelled with the `link` message, `unlink` as the danger outline `user-minus` button labelled with the `unlinkSelected` message and disabled without a selection. The linked list reuses `DataTable` with 56px rows inside a 12px-radius bordered container: selection checkbox, the first view column as an initials avatar (30px) plus text at 600 weight, the remaining view columns muted, pagination from the list meta, and the relation `empty` message when nothing is linked. Unlink confirms with the plural `unlinkConfirm` message, POSTs `unlink` with `{ids}`, clears the selection, reloads and toasts the plural `unlinked` message.
`RelationPickerModal.vue` (Reka Dialog): 560px wide, radius 20, padding 24, backdrop from the overlay token, `role="dialog"` with `aria-modal`, title from the `link` message (20px/700), helper from `linkHint`, a 34px close button (`x`, subtle background, labelled from `t`), a 44px search input with autofocus and the `candidateSearch` placeholder, and the candidate list as a `role="listbox"` with `aria-multiselectable="true"` loaded from `.../candidates` with `per_page=5`, the search term and the page. Each option is a 54px row (radius 12, border): a 32px initials avatar, the first manage column at 600 weight above the second manage column at 13px muted, and a checkbox; a selected option has the sel background and a `#e0b020` border. Selection is a set kept across pages and cleared on close. The pager shows `:from–:to z :total` and 34px previous and next buttons (radius 9). The footer has two equal-width buttons: Anuluj (outline) and `Dodaj (:count)` (primary, disabled when the set is empty). Confirming POSTs `link` with the ids, closes, reloads the linked list and toasts the plural `linked` message. Esc closes, focus stays inside while open, and focus returns to the triggering button on close. The SPA never filters candidates itself; the owner exclusion is the server's.
Add the new keys to both backend lang files, neutral relation fixtures (`acme.demo.widgets` relation `members` with columns `name` and `email`), and `tests/smoke/relation.smoke.test.ts` (hidden on create; linked list render and search; unlink confirm and body; modal paging at five, selection across pages, disabled at zero, link body, focus return, Esc), then rebuild `boardwalk/dist`.</action>
<verify>
<automated>npm --prefix admin run typecheck &amp;&amp; npm --prefix admin test -- tests/smoke/relation.smoke.test.ts &amp;&amp; go test ./phrasebook -run '^TestPhase10SPAKeysResolve$' -count=1 -v &amp;&amp; scripts/check-admin-dist.sh &amp;&amp; (cd ../fonoteka.go &amp;&amp; go test ./plugins/golem15/fonoteka -run '^TestCollectionsAdmin(RelationSchema|Link|Unlink|CrossScope|RelationEdges)$' -count=1 -v)</automated>
<fails_when>Any command exits non-zero; vitest prints "No test files found" or any failed test; a go test output lacks its "--- PASS" lines or shows "no tests to run" or SKIP; check-admin-dist.sh prints a diff.</fails_when>
<human-check>
<test>With `summer serve` running in ../fonoteka.go, open http://localhost:8080/plytadmin, log in, open Kolekcje, edit a collection and switch to the Edytorzy tab; search, add two editors in the modal, then remove one.</test>
<expected>The tab is absent on the create form; the modal lists users five per page without the owner, Dodaj (2) links both and the toast confirms; removing asks for confirmation and the row disappears; focus returns to the Dodaj button after closing the modal.</expected>
<why_human>Focus management and the real link/unlink round trip in a browser are outside Vitest's DOM (D-23: no Playwright).</why_human>
</human-check>
</verify>
<acceptance_criteria>
- The relation smoke test passes and covers every behaviour listed at the end of the action.
- `grep -c "'relation-manager'" admin/src/components/form/registry.ts` prints 1.
- `grep -c 'aria-multiselectable' admin/src/components/relation/RelationPickerModal.vue` prints at least 1.
- The fonoteka Collections relation suites still pass through the prefix.
</acceptance_criteria>
<done>On an existing Collection an admin can search editors, add several through the picker and remove selected ones, with confirmations and toasts.</done>
</task>
<task type="auto">
<name>Task 2: The shell matches the design: collapsible panel with flyout, user menu with logout, breadcrumbs and dark mode</name>
<files>admin/src/components/shell/AppShell.vue, admin/src/components/shell/PluginRail.vue, admin/src/components/shell/SectionPanel.vue, admin/src/components/shell/SectionFlyout.vue, admin/src/components/shell/UserMenu.vue, admin/src/components/shell/Breadcrumbs.vue, admin/src/components/ui/Toast.vue, admin/src/state/useSidebar.ts, admin/src/state/useAuth.ts, admin/src/state/useToasts.ts, admin/src/app/theme.ts, admin/src/main.ts, admin/src/styles/main.css, admin/tests/smoke/shell.smoke.test.ts, phrasebook/backend/lang/en/lang.yaml, phrasebook/backend/lang/pl/lang.yaml, boardwalk/dist/**</files>
<read_first>admin/src/components/shell/AppShell.vue, admin/src/components/shell/PluginRail.vue, admin/src/components/shell/SectionPanel.vue, admin/src/state/useNavigation.ts, admin/src/state/useAuth.ts, admin/src/app/icons.ts, admin/src/styles/main.css, .planning/phases/10-admin-vue-spa/design/README.md (screen 2, Top header, Transitions, Responsive)</read_first>
<action>Per D-06/D-10:
(1) Sidebar state: `src/state/useSidebar.ts` combines `matchMedia('(max-width: 1099px)')` (forces collapsed without overwriting the manual preference) with a manual flag persisted in localStorage under `summer-admin.sidebar` (boolean only). SectionPanel (224px) gets its header with the plugin label at 16px/700 and a 30px "Zwiń menu" button (`panel-left-close`); when collapsed only the 80px rail remains and a 40px "Rozwiń menu" button (`panel-left-open`) appears above Ustawienia.
(2) Flyout: `SectionFlyout.vue` opens in collapsed mode when a rail item is hovered or focused, or on Enter or ArrowDown: 230px wide, 8px right of the rail, aligned with the header area, surface background, border, radius 14, padding 8, shadow `0 16px 40px rgba(20,27,45,.18)`, a bold plugin title and 38px items (radius 9) with the panel's idle and active styles; `role="menu"` with menuitems, closes on Esc or about 200 ms after mouse-leave, and returns focus to the rail item. Clicking a rail item navigates to its first permitted side-menu controller (or the entry's controller); the active rail item is the one owning the route's vendor and plugin.
(3) Header: 64px surface header with `Breadcrumbs.vue` (plugin label, controller label, record title; parent crumbs are muted links, the current crumb is text colour at 600; `chevron-right` 14px separators) and `UserMenu.vue` (Reka DropdownMenu): a 44px button with a 34px accent avatar of initials (first and last name, else login), the name (600) above the role name from `/auth/me` (12px muted; both hidden at tablet width) and `chevron-down`; the 280px menu (radius 14, padding 8) holds a header with a 40px avatar, the bold name and muted email, a divider and a Wyloguj item (`log-out`). `useAuth().logout()` POSTs `/auth/logout`, clears the in-memory user and navigation, and routes to login even when the call fails.
(4) Theme and polish: `src/app/theme.ts` applies the `.dark` class on the document element from `prefers-color-scheme: dark` and follows changes (A6: no toggle); the rail keeps the dark side colour in both modes with the `#222b40` right border in dark mode. Toasts keep one queue and show the most recent first; transitions are 150 ms ease-out for hover and background and 200 ms fade plus scale from 0.98 for dialogs; no decorative animation.
(5) Add the new keys to both backend lang files and `tests/smoke/shell.smoke.test.ts` (persisted collapse and viewport-forced collapse, flyout open by keyboard and Esc with focus return, user menu shows role and logout calls the API then routes to login even on failure, breadcrumbs for a record route, dark class follows a mocked matchMedia), then rebuild `boardwalk/dist`.</action>
<verify>
<automated>npm --prefix admin run typecheck &amp;&amp; npm --prefix admin test -- tests/smoke &amp;&amp; go test ./phrasebook -run '^TestPhase10SPAKeysResolve$' -count=1 -v &amp;&amp; scripts/check-admin-dist.sh</automated>
<fails_when>Any command exits non-zero; vitest prints "No test files found" or any failed test; the go test output lacks "--- PASS: TestPhase10SPAKeysResolve" or shows "no tests to run"; check-admin-dist.sh prints a diff.</fails_when>
<human-check>
<test>With `summer serve` running in ../fonoteka.go, compare http://localhost:8080/plytadmin (login, Albumy list, an album form, Kolekcje editors with the modal open) against `.planning/phases/10-admin-vue-spa/design/Direction C v2.dc.html` at 1280px and at about 900px width, in light and in dark system mode; log in once as a limited admin (only golem15.fonoteka.access_genres) and once as a superuser.</test>
<expected>Tokens, typography, radii, control heights, focus rings and copy match the design; below about 1100px only the rail shows and the flyout opens from it; the user menu shows the role and Wyloguj returns to login; the limited admin sees only Gatunki in the Fonoteka side menu while the superuser sees every entry.</expected>
<why_human>Visual fidelity and responsive behaviour are judged against the design reference; D-23 excludes browser e2e.</why_human>
</human-check>
</verify>
<acceptance_criteria>
- All smoke tests under `tests/smoke` pass, including the shell cases listed in item (5).
- `grep -rn 'localStorage' admin/src | grep -v 'summer-admin.sidebar' | grep -v 'useSidebar'` prints nothing.
- `grep -c 'auth/logout' admin/src/state/useAuth.ts` prints at least 1.
</acceptance_criteria>
<done>The admin shell behaves like the design at desktop and tablet widths in light and dark mode, and an admin can see their role and log out.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| SPA → relation link/unlink | Selected ids cross into pivot writes |
| Browser storage | Anything persisted survives the session on a shared machine |
| UserMenu → logout | The session must end even when the network call fails |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-10-21 | Elevation of Privilege | relation link of candidates outside scope (owner, inactive users) | medium | transfer | Enforced server-side by Phase 9 (RelationExtendManageQuery, ExcludedRelatedIDs, TestCollectionsAdminForgedPivot/CrossScope); the SPA only posts ids chosen from the server's candidates and runs those suites as a regression in Task 1. |
| T-10-22 | Information Disclosure | localStorage | low | mitigate | Only the sidebar boolean is stored under summer-admin.sidebar; acceptance grep rejects any other localStorage use. |
| T-10-23 | Spoofing | logout on a shared browser | medium | mitigate | logout POSTs /auth/logout (server blacklists the jti and expires the HttpOnly cookie, Plan 10-01) and the SPA clears state and routes to login even on failure; shell smoke test covers both paths. |
| T-10-SC | Tampering | npm dependencies | high | mitigate | No new package; npm ci against the lockfile approved in Plan 10-01. |
</threat_model>
<verification>
Run `npm --prefix admin run typecheck && npm --prefix admin test`, `go test ./phrasebook -count=1`, `scripts/check-admin-dist.sh`, and `(cd ../fonoteka.go && go test ./plugins/golem15/fonoteka -run '^TestCollectionsAdmin' -count=1)`. A non-zero exit, a failed or missing smoke test, or a printed diff fails the plan.
</verification>
<success_criteria>
- The Collections editors relation manager searches, links and unlinks (SC-3).
- The shell matches the design's responsive, flyout, user menu, breadcrumb and dark-mode behaviour (D-06).
</success_criteria>
<output>
Create `.planning/phases/10-admin-vue-spa/10-04-SUMMARY.md` when done.
</output>

View File

@@ -0,0 +1,284 @@
---
phase: 10-admin-vue-spa
plan: 05
type: execute
wave: 5
depends_on: [10-01, 10-02, 10-03, 10-04]
files_modified:
- admin/vitest.config.ts
- admin/tests/setup.ts
- admin/tests/fixtures/**
- admin/tests/app/i18n.test.ts
- admin/tests/app/winterUrl.test.ts
- admin/tests/app/listQuery.test.ts
- admin/tests/app/runtime.test.ts
- admin/tests/app/icons.test.ts
- admin/tests/app/client.test.ts
- admin/tests/app/router.test.ts
- admin/tests/state/useAuth.test.ts
- admin/tests/state/useNavigation.test.ts
- admin/tests/state/useSidebar.test.ts
- admin/tests/state/useToasts.test.ts
- admin/tests/state/useSettings.test.ts
- admin/tests/shell/AppShell.test.ts
- admin/tests/shell/PluginRail.test.ts
- admin/tests/shell/SectionPanel.test.ts
- admin/tests/shell/SectionFlyout.test.ts
- admin/tests/shell/UserMenu.test.ts
- admin/tests/shell/Breadcrumbs.test.ts
- admin/tests/list/DataTable.test.ts
- admin/tests/list/ListToolbar.test.ts
- admin/tests/list/FilterBar.test.ts
- admin/tests/list/Pagination.test.ts
- admin/tests/list/CellValue.test.ts
- admin/tests/list/ListView.test.ts
- admin/tests/form/registry.test.ts
- admin/tests/form/FormGrid.test.ts
- admin/tests/form/FormField.test.ts
- admin/tests/form/FormTabs.test.ts
- admin/tests/form/fields.test.ts
- admin/tests/form/RelationField.test.ts
- admin/tests/form/FormView.test.ts
- admin/tests/form/Settings.test.ts
- admin/tests/relation/RelationManager.test.ts
- admin/tests/relation/RelationPickerModal.test.ts
- admin/tests/views/LoginView.test.ts
- boardwalk/boardwalk_test.go
- bouncer/cookie_guard_test.go
- cabana/phase10_coverage_test.go
- phrasebook/phase10_test.go
- surf/admin_prefix_test.go
- internal/tools/swagger2openapi/main_test.go
- ../fonoteka.go/plugins/golem15/fonoteka/admin_phase10_e2e_test.go
- scripts/check-phase10.sh
- .planning/phases/10-admin-vue-spa/10-SECURITY-REVIEW.md
- .planning/phases/10-admin-vue-spa/10-VALIDATION.md
autonomous: true
requirements: [ADMIN-06]
estimate:
tokens: 110000
raw_tokens: 110000
tasks: 3
confidence: low
must_haves:
truths:
- "Per D-23 and the project rule that unit tests close every phase, Vitest suites exist for every module, composable, component and view under admin/src (tests/app, tests/state, tests/shell, tests/list, tests/form, tests/relation, tests/views) with neutral acme fixtures, and npm --prefix admin test passes with no browser e2e dependency."
- "Every Phase 10 Go change (cabana prefix/cookie/CSRF/options/relation save/messages/toolbar/filters/bundle, boardwalk, phrasebook Forms/Bundle/overrides, bouncer cookie guard, surf prefix collision, swagger2openapi unions) has named tests covering its success and failure branches, and both repositories pass go vet ./... and go test ./..."
- "TestPhase10AssembledAcceptance proves the four success criteria through the real router at /plytadmin over cookie transport: a limited admin sees only permitted navigation while a developer sees every entry (SC-1); each of Albums, Artists, Collections, Genres and Styles serves its list, form schema, create and update (SC-2); the Collections editors relation searches candidates, links and unlinks (SC-3); every path it calls exists in admin/openapi/admin.json (SC-4)."
- "scripts/check-phase10.sh --all exits non-zero on any failing stage, a go test run that matches zero tests or skips one, OpenAPI or dist drift, a hygiene violation, or an evidence gap; --self-test proves each of those detectors fails closed."
- "10-SECURITY-REVIEW.md lists T-10-01 through T-10-25 and T-10-SC with disposition, production mitigation, and the exact test or gate stage that fails when the mitigation is removed; 10-VALIDATION.md maps every plan task to its command with nyquist_compliant true only after the gate passes."
- statement: "[flagged assumption SC-4] 'No hand-maintained duplicate type' is enforced mechanically: admin/src/api holds only the generated schema.d.ts, the client, and type aliases onto generated schemas; no interface or type literal re-declares an API payload."
verification: backstop
artifacts:
- path: "scripts/check-phase10.sh"
provides: "Fail-closed Phase 10 gate with self-test, go, security, postgres, spa, openapi, dist, hygiene, evidence and all stages"
- path: "../fonoteka.go/plugins/golem15/fonoteka/admin_phase10_e2e_test.go"
provides: "Assembled PostgreSQL acceptance for SC-1 to SC-4"
- path: "cabana/phase10_coverage_test.go"
provides: "Remaining branch coverage for Phase 10 cabana changes"
- path: ".planning/phases/10-admin-vue-spa/10-SECURITY-REVIEW.md"
provides: "Threat-to-test evidence ledger"
key_links:
- from: "scripts/check-phase10.sh"
to: "go test -json output"
via: "zero-test, skip and failure detection per stage"
pattern: "phase10_detect"
- from: "scripts/check-phase10.sh"
to: "scripts/check-admin-openapi.sh and scripts/check-admin-dist.sh"
via: "openapi and dist stages"
pattern: "check-admin"
- from: "10-VALIDATION.md"
to: "10-01..10-05 task verify commands"
via: "per-task verification map"
pattern: "10-0"
prohibitions:
- "[flagged-unverified] Phase acceptance must not depend on skipped PostgreSQL tests, zero-test runs, or a hand-edited boardwalk/dist or schema.d.ts."
- "[flagged-unverified] No test or fixture inside summercms.go may use Płytarium or fonoteka names; app names appear only in fonoteka.go tests."
- "[flagged-unverified] A high threat must not be marked mitigated without naming the executable test or gate stage that fails when the mitigation is removed."
---
## 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.
<objective>
Close Phase 10 with full unit test coverage (the project rule: unit tests are the last plan), an assembled acceptance test for all four success criteria, and one fail-closed gate script with security evidence.
Purpose: Plans 10-01 to 10-04 carried smoke tests only; this plan brings every SPA module and every Go change to named, branch-level tests and makes the phase acceptance a single command. Decisions implemented: D-23 (Vitest plus Go tests, no Playwright), D-04 (drift gates in the phase gate), D-15/D-16 (SC-4 hygiene), D-28 (this is plan 05).
Output: Vitest suites, Go coverage tests in both repos, `scripts/check-phase10.sh`, `10-SECURITY-REVIEW.md`, finalized `10-VALIDATION.md`.
Repos: Task 1 summercms.go; Task 2 summercms.go and fonoteka.go; Task 3 summercms.go (script) plus planning docs (separate docs commit). Never add co-author tags.
</objective>
<execution_context>
@~/.claude/gsd-core/workflows/execute-plan.md
@~/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.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/10-VALIDATION.md
@.planning/phases/10-admin-vue-spa/10-01-SUMMARY.md
@.planning/phases/10-admin-vue-spa/10-02-SUMMARY.md
@.planning/phases/10-admin-vue-spa/10-03-SUMMARY.md
@.planning/phases/10-admin-vue-spa/10-04-SUMMARY.md
@scripts/check-phase9.sh
@.planning/phases/09-backend-admin-authentication-and-schema-pipeline/09-SECURITY-REVIEW.md
<interfaces>
Gate pattern to reuse: `scripts/check-phase9.sh` (`phase9_detect` parses `go test -json` and exits 1 on fail, 2 on a skipped test, 3 on zero passing tests; `phase9_go DIR PKGS -run REGEX`; staged `case` dispatch; `--self-test` feeds synthetic bad runs to the detector).
Threat IDs in this phase: T-10-01..T-10-08 and T-10-17 (Plan 10-01), T-10-09..T-10-15 (10-02), T-10-16, T-10-18..T-10-20 (10-03), T-10-21..T-10-23 (10-04), T-10-24..T-10-25 (this plan), T-10-SC (all plans).
</interfaces>
</context>
## Artifacts this phase produces
- Vitest suites under `admin/tests/{app,state,shell,list,form,relation,views}` and shared fixtures/setup
- Go tests: extended `boardwalk/boardwalk_test.go`, `bouncer/cookie_guard_test.go`, `cabana/phase10_coverage_test.go`, extended `phrasebook/phase10_test.go`, extended `surf/admin_prefix_test.go`, `internal/tools/swagger2openapi/main_test.go`, fonoteka `TestPhase10AssembledAcceptance`
- `scripts/check-phase10.sh` with `--self-test`, `--go`, `--security`, `--postgres`, `--spa`, `--openapi`, `--dist`, `--hygiene`, `--evidence`, `--all`; detector `phase10_detect`
- `.planning/phases/10-admin-vue-spa/10-SECURITY-REVIEW.md`; finalized `10-VALIDATION.md` (`nyquist_compliant: true`)
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Bring every SPA module, composable and component under Vitest</name>
<files>admin/vitest.config.ts, admin/tests/setup.ts, admin/tests/fixtures/**, admin/tests/app/i18n.test.ts, admin/tests/app/winterUrl.test.ts, admin/tests/app/listQuery.test.ts, admin/tests/app/runtime.test.ts, admin/tests/app/icons.test.ts, admin/tests/app/client.test.ts, admin/tests/app/router.test.ts, admin/tests/state/useAuth.test.ts, admin/tests/state/useNavigation.test.ts, admin/tests/state/useSidebar.test.ts, admin/tests/state/useToasts.test.ts, admin/tests/state/useSettings.test.ts, admin/tests/shell/AppShell.test.ts, admin/tests/shell/PluginRail.test.ts, admin/tests/shell/SectionPanel.test.ts, admin/tests/shell/SectionFlyout.test.ts, admin/tests/shell/UserMenu.test.ts, admin/tests/shell/Breadcrumbs.test.ts, admin/tests/list/DataTable.test.ts, admin/tests/list/ListToolbar.test.ts, admin/tests/list/FilterBar.test.ts, admin/tests/list/Pagination.test.ts, admin/tests/list/CellValue.test.ts, admin/tests/list/ListView.test.ts, admin/tests/form/registry.test.ts, admin/tests/form/FormGrid.test.ts, admin/tests/form/FormField.test.ts, admin/tests/form/FormTabs.test.ts, admin/tests/form/fields.test.ts, admin/tests/form/RelationField.test.ts, admin/tests/form/FormView.test.ts, admin/tests/form/Settings.test.ts, admin/tests/relation/RelationManager.test.ts, admin/tests/relation/RelationPickerModal.test.ts, admin/tests/views/LoginView.test.ts</files>
<read_first>admin/src (every module being tested), admin/tests/setup.ts, admin/tests/smoke/*.smoke.test.ts, admin/tests/fixtures/*, .planning/phases/10-admin-vue-spa/design/README.md (States, Interactions), phrasebook/translator.go (interpolate, Choice)</read_first>
<behavior>
- app: interpolate parity with phrasebook for :name/:Name/:NAME and overlapping names; plural selection for pl counts 1, 2, 5, 22, 25 and en 1, 2 with fallback to other; t returns the key when missing; mapWinterUrl for empty, create, update/:id, foreign and malformed input; listQuery round-trip including filter[...] and invalid numbers; runtime reads the meta and derives the API base; icons resolve lucide names, Winter aliases and the fallback; client sets X-Requested-With, single-flights refresh for concurrent 401s, replays once, then routes to login with redirect; router guard rejects protocol-relative and absolute redirect values.
- state: useAuth login/logout/me and proactive refresh timing; useNavigation active plugin and first permitted controller; useSidebar viewport versus persisted manual state and storage key; useToasts queue and auto-dismiss; useSettings visibility.
- shell, list, form, relation, views: each component's states from the design (selected, empty, empty search, loading, 422, toast, modal, collapsed, flyout, dark) plus its a11y roles and attributes (aria-sort, aria-current, aria-invalid, aria-describedby, aria-required, role=switch, role=tablist/tab, role=dialog with aria-modal, role=listbox with aria-multiselectable, role=menu, role=status, role=alert).
</behavior>
<action>Per D-23, write Vitest component and unit tests with @vue/test-utils and happy-dom for every file under `admin/src` (the list in `<files>` is one suite per module; add a suite if a module was added after planning). Mock HTTP by injecting a fetch implementation into the openapi-fetch client from `tests/setup.ts` (never a real network), keep fixtures neutral (`acme.demo.*`, labels like "Widgets", "Members"; no Płytarium words) and shaped from real generated types (fixtures are typed by importing the generated `components` schemas in a typed helper so drift fails typecheck). Reproduce the design's extra shapes as fixtures only (D-08): tabs, switch and checkbox toggle cards, single, multiple and read-only relations, an unknown `colorpicker` type, and all three filter shapes. Assert behaviour and accessibility attributes, not snapshots of markup. Keep the smoke tests. `vitest.config.ts` keeps happy-dom and adds `restoreMocks: true`; no coverage-provider package is added (it would be a new dependency).</action>
<verify>
<automated>npm --prefix admin run typecheck &amp;&amp; npm --prefix admin test -- tests/app tests/state tests/shell tests/list tests/form tests/relation tests/views tests/smoke</automated>
<fails_when>Non-zero exit; vitest prints "No test files found" for any listed directory, or any "FAIL" line; vue-tsc reports an error.</fails_when>
</verify>
<acceptance_criteria>
- Every `.ts` and `.vue` file under `admin/src` except `main.ts`, `api/schema.d.ts` and `env.d.ts` is imported by at least one test file (checked by the hygiene stage in Task 3).
- `grep -rniE 'pl[yý]tarium|fonoteka|albumy|kolekcj' admin/tests` prints nothing.
- `grep -rn 'playwright' admin/package.json` prints nothing.
</acceptance_criteria>
<done>The whole SPA has behaviour and accessibility tests that run offline in seconds.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Cover every Phase 10 Go change and prove the four success criteria end to end</name>
<files>boardwalk/boardwalk_test.go, bouncer/cookie_guard_test.go, cabana/phase10_coverage_test.go, phrasebook/phase10_test.go, surf/admin_prefix_test.go, internal/tools/swagger2openapi/main_test.go, ../fonoteka.go/plugins/golem15/fonoteka/admin_phase10_e2e_test.go</files>
<read_first>boardwalk/boardwalk.go, bouncer/jwt.go, cabana/prefix.go, cabana/csrf.go, cabana/auth.go, cabana/http.go, cabana/relation_field.go, cabana/messages.go, cabana/lang.go, cabana/list_schema.go, cabana/filter_schema.go, phrasebook/loader.go, phrasebook/translator.go, surf/router.go, internal/tools/swagger2openapi/main.go, ../fonoteka.go/plugins/golem15/fonoteka/admin_phase09_e2e_test.go, ../fonoteka.go/plugins/golem15/fonoteka/admin_paths_test.go</read_first>
<behavior>
- bouncer TestPhase10CookieGuard: the backend guard reads summer_admin when no Bearer is present, Bearer wins when both are, an empty cookie is unauthenticated, a frontend-audience token in the cookie is rejected, a blacklisted jti in the cookie is rejected.
- boardwalk: HEAD requests, query strings, encoded traversal, index.html requested by name gets the rewritten bytes, prefix with nested segments, extensionless unknown path returns index, unknown extension type falls back to mime.
- cabana TestPhase10Coverage: every mounted unsafe route (including the options, filters and lang routes added later) is covered by the CSRF walk; options and filters edge cases (empty search, page beyond last, per_page above cap); relation labels for read-only fields; messages defaults for relation schemas; toolbar create omission without a form; bundle fallback to app.fallback_locale; refresh with an expired access token inside the refresh window via cookie.
- phrasebook: override precedence (override beats plugin beats framework), a new locale via override, Bundle merge order, Forms for every entry shape.
- surf: the exact prefix path and a deeper path both collide; a sibling path such as /backendx does not.
- swagger2openapi TestUnionRewrite: jsonScalar and fieldContext become the documented unions and other components are untouched.
- fonoteka TestPhase10AssembledAcceptance: SC-1 to SC-4 as stated in must_haves.
</behavior>
<action>Write the tests in `<behavior>` using the existing Testcontainers PostgreSQL helpers where a database is needed (cabana's helper and fonoteka's `bootDB`/`bootConfig`), never an in-memory substitute for assembled cases. `TestPhase10AssembledAcceptance` seeds a developer-role admin, a limited admin granted only `golem15.fonoteka.access_genres`, a matching frontend user for the Albums collection resolver (Phase 9 D-14), a genre, two artists and a second user; then, through `adminAPI(...)` at `/plytadmin` with cookie login and `X-Requested-With`: compares both admins' `/navigation` (limited sees only Genres under Fonoteka, developer sees every side-menu entry including Collections); for each of the five controllers GETs `schema/list`, the list, `schema/form`, POSTs a record (album with genre and ordered artists) and PUTs an update; on a Collection GETs editors candidates with a search term, links the second user, sees it in the linked list, unlinks it; GETs and PUTs the fonoteka settings; loads `/lang`; logs out and gets 401 with the old cookie; finally asserts every path template it called exists in `admin/openapi/admin.json` of the framework (read via `../summercms.go/admin/openapi/admin.json` from the app test, the app knowing the framework is the allowed direction). Fill any branch the earlier plans left without a named test (extend the listed files only).</action>
<verify>
<automated>go vet ./... &amp;&amp; go test ./boardwalk ./bouncer ./cabana ./phrasebook ./surf ./internal/tools/swagger2openapi -count=1 &amp;&amp; go test ./bouncer ./cabana ./phrasebook ./surf ./internal/tools/swagger2openapi -run '^Test(Phase10CookieGuard|Phase10Coverage|Phase10Forms|Phase10LangOverride|Phase10AdminPrefixCollision|UnionRewrite)$' -count=1 -v &amp;&amp; (cd ../fonoteka.go &amp;&amp; go vet ./... &amp;&amp; go test ./plugins/golem15/fonoteka -run '^TestPhase10AssembledAcceptance$' -count=1 -v &amp;&amp; go test ./... -count=1)</automated>
<fails_when>Any command exits non-zero; the verbose runs lack a "--- PASS" line for TestPhase10CookieGuard, TestPhase10Coverage, TestPhase10Forms, TestPhase10LangOverride, TestPhase10AdminPrefixCollision, TestUnionRewrite or TestPhase10AssembledAcceptance, or print "no tests to run" or a SKIP.</fails_when>
</verify>
<acceptance_criteria>
- Each behavior above is a named passing test; TestPhase10AssembledAcceptance runs against real PostgreSQL and exercises SC-1 to SC-4.
- Both repositories pass `go vet ./...` and `go test ./...`.
- `grep -rniE 'pl[yý]tarium|fonoteka' boardwalk bouncer cabana phrasebook surf internal/tools --include=*_test.go` prints nothing.
</acceptance_criteria>
<done>Every Go change in Phase 10 has branch-level tests, and one assembled test proves the four success criteria through the real router.</done>
</task>
<task type="auto">
<name>Task 3: One fail-closed Phase 10 gate plus threat and validation evidence</name>
<files>scripts/check-phase10.sh, .planning/phases/10-admin-vue-spa/10-SECURITY-REVIEW.md, .planning/phases/10-admin-vue-spa/10-VALIDATION.md</files>
<read_first>scripts/check-phase9.sh, scripts/check-admin-openapi.sh, scripts/check-admin-dist.sh, .planning/phases/10-admin-vue-spa/10-VALIDATION.md, .planning/phases/09-backend-admin-authentication-and-schema-pipeline/09-SECURITY-REVIEW.md, every 10-0N-PLAN.md threat_model and verify block, every 10-0N-SUMMARY.md</read_first>
<action>(1) `scripts/check-phase10.sh` (bash, set -euo pipefail, committed with the executable bit), modelled on `scripts/check-phase9.sh`: `phase10_detect` parses `go test -json` (exit 1 fail, 2 skip, 3 zero tests, 4 non-JSON); `phase10_go DIR PKGS -run REGEX` runs it. Stages: `--self-test` (bash -n, detector fails on synthetic fail, skip and zero-test runs, every mode flag present, the hygiene stage fails on a temporary fixture containing a raw-HTML directive in a scratch copy); `--go` (go vet and go test ./... in both repos); `--security` (TestPhase10CookieAuth, TestPhase10CSRF, TestPhase10Prefix, TestPhase10CookieGuard, TestPhase10RelationForgedID, TestPhase10CollectionOwnerReadOnly, TestPhase10Bundle, boardwalk tests, plus `scripts/check-phase9.sh --security`); `--postgres` (TestPhase10TracerSPA, TestPhase10AdminAuth, TestPhase10AlbumRelations, TestPhase10Controllers, TestPhase10AssembledAcceptance through the detector so a skip or zero match fails); `--spa` (`npm --prefix admin ci`, typecheck, `npm --prefix admin test`); `--openapi` (`scripts/check-admin-openapi.sh --check`, TestPhase10OpenAPIConformance, TestPhase09ContractInventory, fonoteka `scripts/check-openapi.sh`); `--dist` (`scripts/check-admin-dist.sh`); `--hygiene` (fails when: summercms.go files outside `.planning` under `admin/src`, `admin/tests`, `admin/openapi`, `boardwalk`, `cabana`, `phrasebook` match Płytarium, fonoteka or Polish catalogue domain words; `admin/src` contains a raw-HTML directive; any file under `admin/src` other than `api/client.ts` calls the fetch API directly; `admin/src/api` holds anything but `schema.d.ts`, `client.ts` and `types.ts`, or `types.ts` declares an interface or object type literal instead of aliases onto generated schemas; `boardwalk/dist` references another origin; an icon namespace import or the deprecated lucide package appears; a route literal for the retired admin prefix appears in Go code other than MintAudience issuer arguments; a `.ts` or `.vue` file under `admin/src` other than `main.ts`, `env.d.ts` and `api/schema.d.ts` is imported by no test); `--evidence` (review lists T-10-01..T-10-25 and T-10-SC, validation has `nyquist_compliant: true`, no pending row, and names ADMIN-06; then runs --security, --postgres and --openapi); `--all` runs every stage in order.
(2) `10-SECURITY-REVIEW.md`: a fresh code-and-test review of every threat in the five plans' registers (T-10-01..T-10-25, T-10-SC): threat, severity, disposition, the production mitigation with file references, the exact test or gate stage, the observed result, residual risk. A high threat is marked mitigated only when the named test fails if the protection is removed (state how that was checked). Accepted and transferred threats keep their rationale verbatim from the originating plan.
(3) `10-VALIDATION.md`: replace the seeded rows with the actual plan/task IDs and commands from the executed plans, record per-row status from the final gate, fill Wave 0 items, keep the two manual-only rows (visual fidelity; full browser flow) pointing at the human-check blocks of Plans 10-03 and 10-04, and set `nyquist_compliant: true` and `wave_0_complete: true` only after `scripts/check-phase10.sh --all` passes.
(4) Run `scripts/check-phase10.sh --all`; commit the script with the code and the two docs in a separate docs commit.</action>
<verify>
<automated>scripts/check-phase10.sh --self-test &amp;&amp; scripts/check-phase10.sh --all</automated>
<fails_when>Non-zero exit, or any output line starting with "refuse:"; the final line "phase10 all passed" is absent.</fails_when>
</verify>
<acceptance_criteria>
- `scripts/check-phase10.sh --all` exits 0 and prints "phase10 all passed".
- `grep -c 'T-10-' .planning/phases/10-admin-vue-spa/10-SECURITY-REVIEW.md` is at least 26 and every T-10 ID from the five plans appears.
- `grep -c 'nyquist_compliant: true' .planning/phases/10-admin-vue-spa/10-VALIDATION.md` prints 1 and no table row in it contains "pending".
</acceptance_criteria>
<done>Phase 10 has one command that proves everything, and auditable threat and validation evidence.</done>
</task>
</tasks>
## Multi-Source Coverage Audit
| Source | Item | Coverage | Plan evidence |
|--------|------|----------|---------------|
| GOAL | SPA renders login, permission-gated navigation, lists, forms and the relation manager for the five controllers, typed from the generated OpenAPI document | COVERED | 10-01 tracer (login, nav, typed list); 10-02 contract; 10-03 lists/forms/settings; 10-04 relation manager and shell; 10-05 assembled acceptance |
| REQ | ADMIN-06 | COVERED | 10-01, 10-02, 10-03, 10-04, 10-05 |
| GOAL | SC-1 login and permission-filtered navigation | COVERED | 10-01 Tasks 2-3; 10-04 Task 2; TestPhase10AssembledAcceptance |
| GOAL | SC-2 five controllers list and form | COVERED | 10-02 Task 3 TestPhase10Controllers; 10-03 Tasks 1-3 |
| GOAL | SC-3 Collections relation manager search/link/unlink | COVERED | 10-04 Task 1; TestPhase10AssembledAcceptance |
| GOAL | SC-4 generated types, no hand-maintained duplicates | COVERED | 10-01 Task 2 pipeline; 10-02 Task 3 full typing and conformance; 10-05 hygiene stage |
| CONTEXT | D-01, D-02, D-03, D-04 packaging, prefix, API move, committed dist | COVERED | 10-01 Tasks 2-3; 10-05 dist stage |
| CONTEXT | D-05 field renderer registry and UnsupportedField | COVERED | 10-03 Tasks 1 and 3; 10-04 Task 1 |
| CONTEXT | D-06, D-07 design fidelity and stack | COVERED | 10-01 Task 2 tokens/fonts/stack; 10-03; 10-04 Task 2 |
| CONTEXT | D-08 real YAML only, mock extras as fixtures | COVERED | 10-02 Task 3 TestPhase10Controllers; 10-03 fixtures; 10-05 Task 1 |
| CONTEXT | D-09 error envelope and 422 mapping | COVERED | 10-03 Task 1 |
| CONTEXT | D-10 routes from controller IDs, rail grouping, server filtering | COVERED | 10-01 Task 2; 10-04 Task 2 |
| CONTEXT | D-11 lucide icons, Winter map, fonoteka icons | COVERED | 10-01 Tasks 2-3 |
| CONTEXT | D-12 format column plain text | COVERED | 10-03 Task 2 |
| CONTEXT | D-13, D-24 messages and placeholder syntax | COVERED | 10-02 Task 2; 10-03 Task 1 |
| CONTEXT | D-14 declarative toolbar | COVERED | 10-02 Task 2; 10-03 Task 2 |
| CONTEXT | D-15, D-16 framework OpenAPI and generic records | COVERED | 10-01 Task 2; 10-02 Task 3 |
| CONTEXT | D-17, D-18 relation options and save | COVERED | 10-02 Task 1; 10-03 Task 3 |
| CONTEXT | D-19 cookie transport and CSRF | COVERED | 10-01 Tasks 2-3 |
| CONTEXT | D-20 backend strings, bundle and overrides | COVERED | 10-02 Task 2; 10-03 Task 1 |
| CONTEXT | D-21 settings screen | COVERED | 10-03 Task 3 |
| CONTEXT | D-22, D-27 filter bar and model-backed filter options | COVERED | 10-02 Task 3; 10-03 Task 2 |
| CONTEXT | D-23 Vitest plus Go tests, no Playwright | COVERED | 10-05 Tasks 1-2 |
| CONTEXT | D-25 Collections navigation item | COVERED | 10-01 Task 3 |
| CONTEXT | D-26 read-only owner | COVERED | 10-02 Task 1; 10-03 Task 3 |
| CONTEXT | D-28 five plans | COVERED | 10-01 to 10-05 |
| RESEARCH | Gaps 1-9 (backend namespace, fonoteka lang, Collections nav, override layer, placeholder syntax, filter choices, page sizes, recordUrl mapping, reserved segments) | COVERED | 10-02 Task 2; 10-01 Task 3; 10-01 Task 3; 10-02 Task 2; 10-02 Task 2; 10-02 Task 3; 10-03 Task 2; 10-03 Task 1; 10-01 Task 3 |
| RESEARCH | Pitfalls 1-13 (embed all:, go.mod ignore, api fallback, toolbar error, delete without checkboxes, scoped ids, requiredByDefault, conformance, font subsets, refresh race, fixture names, dist drift, owner FK) | COVERED | 10-01, 10-02, 10-05 as cited in each task |
| RESEARCH | Package legitimacy audit | COVERED | 10-01 Task 1 blocking-human checkpoint |
| CONTEXT | Deferred ideas (10.1 extension point, Ctrl+K, badge column, Playwright, user/media navigation) | EXCLUDED | No task implements a deferred item |
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Test and gate results → phase acceptance | Gate completeness decides whether a vulnerable admin surface can be declared done |
| Framework repo → app repo boundary | summercms.go must stay free of application knowledge |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-10-24 | Repudiation | Phase 10 acceptance evidence | high | mitigate | check-phase10.sh detects zero-test, skipped and failing go runs, drift, hygiene and evidence gaps; --self-test proves each detector fails closed; the review names a failing-when-broken test per high threat. |
| T-10-25 | Tampering | framework/app boundary and hand-maintained API types | low | mitigate | --hygiene stage fails on app names in summercms.go, non-alias API types, direct fetch calls, raw-HTML directives, foreign origins in dist and untested SPA modules. |
| T-10-SC | Tampering | npm/Go dependencies | high | mitigate | No new package in this plan (no coverage provider); npm ci against the approved lockfile; swag pinned at v1.16.6. |
</threat_model>
<verification>
`scripts/check-phase10.sh --all` is the phase acceptance command. It fails on any non-zero stage, zero matched tests, a skipped PostgreSQL test, OpenAPI or dist drift, a hygiene violation, or an evidence gap. The manual-only checks (visual fidelity, full browser flow) are the human-check blocks in Plans 10-03 and 10-04, collected at /gsd-verify-work.
</verification>
<success_criteria>
- Every SPA module and every Phase 10 Go change has named tests; both repos are green.
- TestPhase10AssembledAcceptance proves SC-1 to SC-4 against real PostgreSQL.
- scripts/check-phase10.sh --all passes; the security review and validation map are complete and truthful.
- The multi-source audit has no missing GOAL, REQ, RESEARCH or CONTEXT item and no deferred item in scope.
</success_criteria>
<output>
Create `.planning/phases/10-admin-vue-spa/10-05-SUMMARY.md` when done.
</output>

View File

@@ -664,21 +664,28 @@ Plans 3 and 4 can merge into one if the user wants four plans.
| 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
## Open Questions (RESOLVED)
1. **Add a Collections side-menu item in fonoteka.go?**
All five questions were resolved at plan time (2026-09-27); the decisions are recorded in 10-CONTEXT.md and implemented by the plans named below.
1. **Add a Collections side-menu item in fonoteka.go?** — RESOLVED by D-25
- Known: neither PHP nor Go navigation has one; the design shows "Kolekcje" (`library`); success criteria 2–3 need Collections reachable.
- Recommendation: add it (`collections`, `library`, permission `golem15.fonoteka.access_collections`) as a deliberate, documented deviation — navigation is not part of the API parity contract. Resolved at discuss/plan time.
2. **Collections `owner` relation field under D-18.**
- **RESOLVED (D-25):** fonoteka.go adds the `collections` side-menu item (lucide `library`, permission `golem15.fonoteka.access_collections`) as a documented deviation from the PHP navigation (Plan 10-01).
2. **Collections `owner` relation field under D-18.** — RESOLVED by D-26
- Known: `owner_id` is a protected fill key; `FormBeforeCreate` forces the owner to the admin's matched user; Winter let admins pick an owner (`emptyOption: current_user`).
- Recommendation: keep `owner` **read-only** in Phase 10 (schema flag `readOnly`/`disabled` for relation fields whose FK is protected; shown as a label from `meta.labels`); do not widen `protectedFillKey`. Revisit only if the user wants owner reassignment.
3. **Placeholder syntax `{count}` vs `:count`.**
- **RESOLVED (D-26):** `owner` is read-only in Phase 10 (relation fields whose foreign key is a protected fill key are served `readOnly` and rendered as a label from `meta.labels`); `protectedFillKey` and `FormBeforeCreate` are not widened (Plan 10-02 Task 1).
3. **Placeholder syntax `{count}` vs `:count`.** — RESOLVED by D-24
- Known: phrasebook and Winter lang files use `:name`; D-13 writes `{count}`.
- Recommendation: keep phrasebook's `:name` syntax in YAML (Winter strings port verbatim, server and SPA interpolate identically) and treat D-13's braces as notation; SPA `interpolate()` mirrors `phrasebook.interpolate` (`:name`, `:Name`, `:NAME`). Needs user confirmation because D-13 is locked.
4. **Choices for model-backed filter scopes (D-22).**
- **RESOLVED (D-24, locked in 10-CONTEXT.md plan-time resolutions):** `messages` placeholders use phrasebook/Winter syntax `:count`, `:name`, `:term` (with `:Name`/`:NAME` casing variants); D-13's braces were notation only, and the SPA's interpolation mirrors `phrasebook.interpolate` (Plans 10-02 Task 2 and 10-03 Task 1).
4. **Choices for model-backed filter scopes (D-22).** — RESOLVED by D-27
- Known: nothing serves options for `type: scope`; `modelClass` is a PHP class string with no Go model registry.
- Recommendation: optional model capability `FilterOptions(scope string) []pact.Option` exposed via `GET .../filters/{scope}/options` (or inlined into the list schema when small). Verified with fixtures only, since Płytarium has no filters.
5. **Where exactly the `backend` lang namespace lives** (`phrasebook/backendlang` embed vs a surf-passed FS). Recommendation: phrasebook embed (simplest, cycle-free).
- **RESOLVED (D-27):** optional model capability `FilterOptions(scope string) []pact.Option` served at `GET {prefix}/api/v1/{vendor}/{plugin}/{controller}/filters/{scope}/options` (not inlined into the list schema), verified with fixtures only (Plan 10-02 Task 3).
5. **Where exactly the `backend` lang namespace lives** (`phrasebook/backendlang` embed vs a surf-passed FS). Recommendation: phrasebook embed (simplest, cycle-free). — RESOLVED
- **RESOLVED (phrasebook embed, as planned in 10-02):** the files live at `phrasebook/backend/lang/{en,pl}/lang.yaml`, embedded by `phrasebook/lang.go` and loaded in `Activate` as namespace `backend` right after `lagoon`; projects override or add locales through `pact.HasLangOverrides` without a Node rebuild (D-20; Plan 10-02 Task 2).
## Environment Availability
@@ -785,7 +792,7 @@ Plans 3 and 4 can merge into one if the user wants four plans.
- 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
- SPA patterns (serving, cookie flow, relation save design): MEDIUM — standard practice fitted to this codebase; Open Questions 1–5 resolved at plan time (D-24 to D-27, phrasebook embed)
**Research date:** 2026-09-27
**Valid until:** 2026-10-27 (JS versions move fast; re-run `npm view` before pinning if planning slips)

View File

@@ -38,19 +38,24 @@ created: "2026-09-27"
## Per-Task Verification Map
Filled by the planner from each PLAN.md `<verify>` block. Requirement → test map from RESEARCH.md:
Filled by the planner from each PLAN.md `<verify>` block (cwd = summercms.go; the app repo is reached via `(cd ../fonoteka.go && ...)`). Plan 10-05 Task 3 replaces statuses with the final gate result.
| 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 |
| Task ID | Plan | Wave | Requirement / Decisions | Threat Ref | Test Type | Automated Command | File Exists | Status |
|---------|------|------|-------------------------|------------|-----------|-------------------|-------------|--------|
| 10-01-T1 | 01 | 1 | ADMIN-06 (package gate) | T-10-SC | human (blocking-human) | n/a — npm package legitimacy checkpoint before install | n/a | ⬜ pending |
| 10-01-T2 | 01 | 1 | ADMIN-06 SC1, SC4; D-01 D-02 D-03 D-06 D-07 D-10 D-11 D-15 D-16 D-19 | T-10-01 T-10-02 T-10-03 T-10-04 T-10-17 | assembled Postgres + unit + smoke + script | `(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` | ❌ W0 (created by task) | ⬜ pending |
| 10-01-T3 | 01 | 1 | ADMIN-06 SC1; D-04 D-11 D-19 D-25 | T-10-05 T-10-06 T-10-07 T-10-08 | unit + assembled + script | `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` | ❌ W0 (created by task) | ⬜ pending |
| 10-02-T1 | 02 | 2 | ADMIN-06 SC2; D-17 D-18 D-26 | T-10-09 T-10-10 T-10-11 | unit + assembled Postgres | `go test ./cabana -run '^TestPhase10Relation(Options\|Save\|ForgedID\|Boot)$\|^TestPhase09(PermissionMatrix\|ContractInventory)$' -count=1 -v && (cd ../fonoteka.go && go test ./plugins/golem15/fonoteka -run '^(TestPhase10AlbumRelations\|TestPhase10CollectionOwnerReadOnly\|TestAlbumsAdmin.*\|TestCollectionsAdmin.*)$' -count=1) && scripts/check-admin-openapi.sh --check` | ❌ W0 (created by task) | ⬜ pending |
| 10-02-T2 | 02 | 2 | ADMIN-06 SC2; D-13 D-14 D-20 D-24 | T-10-12 T-10-13 | unit + assembled | `go test ./phrasebook ./cabana -run '^TestPhase10(Forms\|LangOverride\|SPAKeysResolve\|Bundle\|Messages\|Toolbar)$\|^TestPhase09(PermissionMatrix\|ContractInventory)$' -count=1 -v && go test ./internal/build -run '^Test.*AdminController' -count=1 && (cd ../fonoteka.go && go test ./plugins/golem15/fonoteka -run '^(TestPhase10ControllerCopy\|TestPhase10LangCatalog)$' -count=1 -v) && scripts/check-admin-openapi.sh --check` | ❌ W0 (created by task) | ⬜ pending |
| 10-02-T3 | 02 | 2 | ADMIN-06 SC2, SC4; D-08 D-15 D-16 D-27 | T-10-14 T-10-15 | unit + assembled + script | `go test ./cabana -run '^TestPhase10(FilterOptions\|OpenAPIConformance)$\|^TestPhase09(PermissionMatrix\|ContractInventory)$' -count=1 -v && scripts/check-admin-openapi.sh --check && npm --prefix admin run typecheck && scripts/check-admin-dist.sh && (cd ../fonoteka.go && go test ./plugins/golem15/fonoteka -run '^TestPhase10Controllers$' -count=1 -v) && go vet ./... && go test ./... && (cd ../fonoteka.go && go vet ./... && go test ./...)` | ❌ W0 (created by task) | ⬜ pending |
| 10-03-T1 | 03 | 3 | ADMIN-06 SC2; D-05 D-09 D-10 D-18 D-20 D-24 | T-10-16 T-10-20 | smoke + unit + script | `npm --prefix admin run typecheck && npm --prefix admin test -- tests/smoke/edit.smoke.test.ts && go test ./phrasebook -run '^TestPhase10SPAKeysResolve$' -count=1 -v && scripts/check-admin-dist.sh` | ❌ W0 (created by task) | ⬜ pending |
| 10-03-T2 | 03 | 3 | ADMIN-06 SC2; D-12 D-13 D-14 D-22 D-27 | T-10-16 T-10-19 | smoke + unit + script | `npm --prefix admin run typecheck && npm --prefix admin test -- tests/smoke/list.smoke.test.ts && go test ./phrasebook -run '^TestPhase10SPAKeysResolve$' -count=1 -v && scripts/check-admin-dist.sh` | ❌ W0 (created by task) | ⬜ pending |
| 10-03-T3 | 03 | 3 | ADMIN-06 SC2; D-05 D-17 D-18 D-21 D-26 | T-10-18 | smoke + unit + script + assembled | `npm --prefix admin run typecheck && npm --prefix admin test -- tests/smoke && go test ./phrasebook -run '^TestPhase10SPAKeysResolve$' -count=1 -v && scripts/check-admin-dist.sh && (cd ../fonoteka.go && go test ./plugins/golem15/fonoteka -run '^TestPhase10Controllers$' -count=1 -v)` + human-check (five controllers in a browser) | ❌ W0 (created by task) | ⬜ pending |
| 10-04-T1 | 04 | 4 | ADMIN-06 SC3; D-05 D-06 | T-10-21 | smoke + assembled + script | `npm --prefix admin run typecheck && npm --prefix admin test -- tests/smoke/relation.smoke.test.ts && go test ./phrasebook -run '^TestPhase10SPAKeysResolve$' -count=1 -v && scripts/check-admin-dist.sh && (cd ../fonoteka.go && go test ./plugins/golem15/fonoteka -run '^TestCollectionsAdmin(RelationSchema\|Link\|Unlink\|CrossScope\|RelationEdges)$' -count=1 -v)` + human-check (editors link/unlink in a browser) | ❌ W0 (created by task) | ⬜ pending |
| 10-04-T2 | 04 | 4 | ADMIN-06 SC1; D-06 D-10 | T-10-22 T-10-23 | smoke + script | `npm --prefix admin run typecheck && npm --prefix admin test -- tests/smoke && go test ./phrasebook -run '^TestPhase10SPAKeysResolve$' -count=1 -v && scripts/check-admin-dist.sh` + human-check (visual fidelity, limited vs superuser rail) | ❌ W0 (created by task) | ⬜ pending |
| 10-05-T1 | 05 | 5 | ADMIN-06 SC1-SC4; D-08 D-23 | T-10-25 | component + unit | `npm --prefix admin run typecheck && npm --prefix admin test -- tests/app tests/state tests/shell tests/list tests/form tests/relation tests/views tests/smoke` | ❌ W0 (created by task) | ⬜ pending |
| 10-05-T2 | 05 | 5 | ADMIN-06 SC1-SC4; D-23 | T-10-24 | unit + assembled Postgres | `go vet ./... && go test ./boardwalk ./bouncer ./cabana ./phrasebook ./surf ./internal/tools/swagger2openapi -count=1 && (cd ../fonoteka.go && go vet ./... && go test ./plugins/golem15/fonoteka -run '^TestPhase10AssembledAcceptance$' -count=1 -v && go test ./... -count=1)` | ❌ W0 (created by task) | ⬜ pending |
| 10-05-T3 | 05 | 5 | ADMIN-06 (phase gate) | T-10-24 T-10-25 | gate script | `scripts/check-phase10.sh --self-test && scripts/check-phase10.sh --all` | ❌ W0 (created by task) | ⬜ pending |
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
@@ -58,12 +63,12 @@ Filled by the planner from each PLAN.md `<verify>` block. Requirement → test m
## 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
- [ ] `admin/package.json` scripts: `dev`, `build`, `typecheck`, `test`, `gen:api` — Plan 10-01 Task 2
- [ ] `admin/vitest.config.ts` + `admin/tests/setup.ts` (fetch mock for openapi-fetch) — Plan 10-01 Task 2 (extended in 10-05 Task 1)
- [ ] `admin/tests/fixtures/` — neutral schema fixtures (tabs, all field types, unsupported type, three filter shapes) — Plans 10-01, 10-03, 10-04 (completed in 10-05 Task 1)
- [ ] `boardwalk/boardwalk_test.go` — Plan 10-01 Task 3 (extended in 10-05 Task 2)
- [ ] `scripts/check-admin-openapi.sh` (10-01 Task 2), `scripts/check-admin-dist.sh` (10-01 Task 3), `scripts/check-phase10.sh` (10-05 Task 3)
- [ ] Go test helper `adminAPI(rel)` for prefix-relative admin API paths in cabana and fonoteka admin tests — Plan 10-01 Task 2
---
@@ -71,8 +76,8 @@ Filled by the planner from each PLAN.md `<verify>` block. Requirement → test m
| 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 |
| Visual fidelity to Direction C v2 (tokens, dark mode, collapse below ~1100px) | ADMIN-06 (D-06) | No browser e2e in Phase 10 (D-23) | Human-check in 10-04 Task 2: 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 | Human-checks in 10-03 Task 3 (five controllers) and 10-04 Tasks 1-2 (editors link/unlink; limited admin vs superuser rail) |
---

View File

@@ -0,0 +1 @@
No external API integration: the SPA calls only the framework's own cabana admin API in the same binary; npm packages are build-time libraries.

View File

@@ -82,7 +82,7 @@
"next": {
"command": "/gsd:progress --next",
"label": "Advance to the next step",
"reason": "Phase 09 of 15 · executing"
"reason": "Phase 10 of 15 · executing"
},
"updated_at": "2026-09-26T01:03:11.369Z"
"updated_at": "2026-09-27T12:11:01.213Z"
}