From 9b98d8409fcfc25e815983ced43853f21ae3be64 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Mon, 28 Sep 2026 22:44:34 +0200 Subject: [PATCH] docs(10.1): record D-18/D-19, resolve research questions, add pattern map Plan checker iteration 1 flagged unresolved research questions and a missing decision note for golang.org/x/net/html. D-18 approves x/net/html for the partial sanitizer; D-19 fixes the Discogs widget fill to [year, format]. STATE marks the phase ready to execute. --- .planning/STATE.md | 28 +- .../10.1-01-PLAN.md | 4 +- .../10.1-03-PLAN.md | 4 +- .../10.1-CONTEXT.md | 4 + .../10.1-PATTERNS.md | 555 ++++++++++++++++++ .../10.1-RESEARCH.md | 13 +- .planning/state.json | 11 +- 7 files changed, 591 insertions(+), 28 deletions(-) create mode 100644 .planning/phases/10.1-runtime-admin-extension-point/10.1-PATTERNS.md diff --git a/.planning/STATE.md b/.planning/STATE.md index a900749..34601c8 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -1,20 +1,19 @@ --- gsd_state_version: "1.0" milestone: v1.0 -current_phase: 9 -current_phase_name: Backend admin authentication and schema pipeline -status: planning -stopped_at: Phase 10.2 complete, ready to plan Phase 9 -last_updated: "2026-09-28T12:12:43.566Z" +current_phase: "10.1" +current_phase_name: Runtime admin extension point +status: executing +stopped_at: Phase 11.1 UI-SPEC approved +last_updated: "2026-09-28T20:34:17.761Z" last_activity: 2026-09-28 last_activity_desc: Phase 10.2 complete, transitioned to Phase 9 -state_head: cd991bbab3d7b3a8cab4d57f5e7504820e580768 +state_head: ccdc01407809e26afa01933506f189f0167f976d progress: - total_phases: 17 + total_phases: 18 completed_phases: 9 - total_plans: 74 + total_plans: 78 completed_plans: 74 - percent: 60 milestone_name: milestone --- @@ -29,9 +28,9 @@ See: .planning/PROJECT.md (updated 2026-09-16) ## Current Position -Phase: 9 — Backend admin authentication and schema pipeline +Phase: 10.1 (Runtime admin extension point) — READY TO EXECUTE Plan: Not started -Status: Ready to plan +Status: Ready to execute Last activity: 2026-09-28 - Completed quick task 260928-lf2: Rewrite module READMEs and root README as professional app-agnostic docs Progress: [██████░░░░] 60% @@ -138,6 +137,7 @@ Progress: [██████░░░░] 60% - Phase 2 edited: edited fields: depends_on (Phase 1), goal (summer parity:* on bonfire, no longer a parallel workstream) - Phase 10.1 inserted after Phase 10: Runtime admin extension point (URGENT) - Phase 10.2 inserted after Phase 10: Nest framework packages under modules and write run docs (URGENT) +- Phase 11.1 inserted after Phase 11: SummerCMS documentation for humans and AI agents, modelled on wintercms.com/docs; after core framework, before Płytarium API port ### Decisions @@ -375,6 +375,6 @@ Items acknowledged and carried forward from previous milestone close: ## Session Continuity -Last session: 2026-09-28T11:20:40.454Z -Stopped at: Phase 10.2 complete, ready to plan Phase 9 -Resume file: None +Last session: 2026-09-28T20:25:35.258Z +Stopped at: Phase 11.1 UI-SPEC approved +Resume file: .planning/phases/11.1-summercms-documentation-for-humans-and-ai-agents/11.1-UI-SPEC.md diff --git a/.planning/phases/10.1-runtime-admin-extension-point/10.1-01-PLAN.md b/.planning/phases/10.1-runtime-admin-extension-point/10.1-01-PLAN.md index e4b783c..96e0b65 100644 --- a/.planning/phases/10.1-runtime-admin-extension-point/10.1-01-PLAN.md +++ b/.planning/phases/10.1-runtime-admin-extension-point/10.1-01-PLAN.md @@ -146,7 +146,7 @@ Existing seams (read, do not re-derive): - Spec-less probe fallback skipped: no requirement IDs were mapped for Phase 10.1 before this planning run; ADMIN-07 is introduced by it (REQUIREMENTS.md). Truths are derived from CONTEXT D-01..D-17 and the UI-SPEC. - D-12's "POST path" is realised as the cabana-owned route `.../toolbar/{action}`: surf refuses any plugin route under the admin prefix (`TestPhase10AdminPrefixCollision`), so a controller registers the action (name, label, permissions, Go handler) and cabana owns the path, CSRF, auth and scope. -- `golang.org/x/net/html` is the dependency named by 10.1-RESEARCH (Standard Stack, Open Question 2); it is already `golang.org/x/net v0.58.0 // indirect` in go.mod, so promoting it adds no module (CLAUDE.md rule 4). +- `golang.org/x/net/html` is the dependency named by 10.1-RESEARCH (Standard Stack, Open Question 2) and approved by CONTEXT D-18; it is already `golang.org/x/net v0.58.0 // indirect` in go.mod, so promoting it adds no module (CLAUDE.md rule 4). Signal: `chosen` ("custom"): toolbar actions change from a closed constant set to controller-registered names. @@ -243,7 +243,7 @@ Decision: promote. The registry is the single namespace for toolbar.buttons and (2) Boot: extension.go compiles every declared partial name (the list's headerPartial and each partial field's path) once per controller: read `{ConfigDir}/_{name}.htm` from the plugin AdminFS (missing file fails boot naming it), require that the controller implements pact.AdminPartialData (else boot error), and parse the source with `template.New(name).Funcs(template.FuncMap{"trans": }).Parse` from html/template (parse errors fail boot). Store the pristine templates on CompiledController in an unexported map plus the set of names declared by form partial fields. Name the new types compiledPartial, PartialNode and PartialView, away from crud.go's partialSelection (Pitfall 13). -(3) Render (D-10, D-17) in new modules/cabana/partial_render.go: `(*compiledPartial).render(ctx, tr, data)` Clones the pristine template (never executed, because Clone fails after Execute), binds `trans` with `.Funcs` to `translateKey(ctx, tr, key)`, Executes with root `map[string]any{"Data": data}` into a writer that fails past `partialMaxBytes` (64 << 10), parses the output with golang.org/x/net/html ParseFragment in a div context, and walks it into []PartialNode with a budget of `partialMaxNodes` (2000) nodes and `partialMaxDepth` (32); exceeding any cap is an error, never a truncated tree. Allowlist (RESEARCH Pattern 4, mirrored later by the SPA): tags div span p strong em b i u s small mark code pre br hr ul ol li dl dt dd h2 h3 h4 h5 h6 table thead tbody tfoot tr th td caption section header footer figure figcaption blockquote q abbr time data meter progress sup sub a img; global attributes class, title, lang, dir, role, aria-* and data-*; per tag: a[href] only when it starts with exactly one "/" (not "//" or "/\") or with "#"; img[src] only a same-origin "/" path (same rule), plus alt, width, height; td and th colspan, rowspan, scope; time datetime; data value; meter value, min, max, low, high, optimum; progress value, max. Drop id, style and every on* attribute. Drop with their whole subtree: script style template iframe object embed noscript textarea title xmp svg math form input button select link meta base. Unwrap any other element (keep its children). Drop comments and doctypes; text nodes become `{text}`. Model guard: when the view model's type, after dereferencing pointers and taking the element type of slices, arrays and maps, equals the type of the controller's NewRecord(), refuse (500). Run `go mod tidy` so golang.org/x/net becomes a direct requirement (already v0.58.0; the go.sum module set must not grow). +(3) Render (D-10, D-17) in new modules/cabana/partial_render.go: `(*compiledPartial).render(ctx, tr, data)` Clones the pristine template (never executed, because Clone fails after Execute), binds `trans` with `.Funcs` to `translateKey(ctx, tr, key)`, Executes with root `map[string]any{"Data": data}` into a writer that fails past `partialMaxBytes` (64 << 10), parses the output with golang.org/x/net/html ParseFragment in a div context, and walks it into []PartialNode with a budget of `partialMaxNodes` (2000) nodes and `partialMaxDepth` (32); exceeding any cap is an error, never a truncated tree. Allowlist (RESEARCH Pattern 4, mirrored later by the SPA): tags div span p strong em b i u s small mark code pre br hr ul ol li dl dt dd h2 h3 h4 h5 h6 table thead tbody tfoot tr th td caption section header footer figure figcaption blockquote q abbr time data meter progress sup sub a img; global attributes class, title, lang, dir, role, aria-* and data-*; per tag: a[href] only when it starts with exactly one "/" (not "//" or "/\") or with "#"; img[src] only a same-origin "/" path (same rule), plus alt, width, height; td and th colspan, rowspan, scope; time datetime; data value; meter value, min, max, low, high, optimum; progress value, max. Drop id, style and every on* attribute. Drop with their whole subtree: script style template iframe object embed noscript textarea title xmp svg math form input button select link meta base. Unwrap any other element (keep its children). Drop comments and doctypes; text nodes become `{text}`. Model guard: when the view model's type, after dereferencing pointers and taking the element type of slices, arrays and maps, equals the type of the controller's NewRecord(), refuse (500). Run `go mod tidy` so golang.org/x/net becomes a direct requirement (D-18) (already v0.58.0; the go.sum module set must not grow). (4) Route: `(*service).partial`, mounted in the backend GroupRaw as `g.Get("/{vendor}/{plugin}/{controller}/partials/{name}", s.partial)` plus constrainController(g) and `g.Where("name", "[A-Za-z_][A-Za-z0-9_]*")`. Order: s.protect; the name must be a declared partial (else 404); query `id` absent means a nil record (header partials, and form partials on create); when present it must be a positive integer and the name must belong to a form partial field (else 404), and the record comes from readScopedRecord (out of scope is 404); call PartialData(ctx, name, record) (an error is logged and answered 500 generic); apply the model guard; render (an error, including a cap, is logged with the controller and partial name and answered 500 generic); `WriteData(w, 200, PartialView{Nodes}, nil)` with Nodes always an array. schema_types.go gains `PartialNode` (`Tag string json:"tag,omitempty"`, `Attrs map[string]string json:"attrs,omitempty"`, `Text string json:"text,omitempty"`, `Children []PartialNode json:"children,omitempty"`) and `PartialView` (`Nodes []PartialNode json:"nodes"`). diff --git a/.planning/phases/10.1-runtime-admin-extension-point/10.1-03-PLAN.md b/.planning/phases/10.1-runtime-admin-extension-point/10.1-03-PLAN.md index cbf3173..eca36d4 100644 --- a/.planning/phases/10.1-runtime-admin-extension-point/10.1-03-PLAN.md +++ b/.planning/phases/10.1-runtime-admin-extension-point/10.1-03-PLAN.md @@ -29,7 +29,7 @@ must_haves: truths: - "Per D-01, D-03 and D-11, the Albums list declares `headerPartial: stats` in config_list.yaml and GET /plytadmin/api/v1/golem15/fonoteka/albums/partials/stats returns a statistics strip for the admin's own collection: the total, one item per stored format with count above zero in getFormatOptions order, and a no-shelf item only when that count is above zero." - "Per D-10, every stats count runs through albumsAdminController.scopeAlbums, and the view model is a curated struct of labels and integers, never an Album model; an admin bound to one collection never sees another collection's counts." - - "Per D-01, D-02, D-04, D-06 and D-07, the Albums form declares a `year` number field after `shelf` and a last, full-width `discogs` widget (widget golem15-fonoteka-discogs-lookup, action discogsLookup, fill [year, format]) that renders on create and update; its stub action answers the message key golem15.fonoteka::lang.discogs.stub_filled with fill {year: 1977, format: \"LP\"} and makes no outbound call; Phase 14 replaces the stub." + - "Per D-01, D-02, D-04, D-06, D-07 and D-19, the Albums form declares a `year` number field after `shelf` and a last, full-width `discogs` widget (widget golem15-fonoteka-discogs-lookup, action discogsLookup, fill [year, format]) that renders on create and update; its stub action answers the message key golem15.fonoteka::lang.discogs.stub_filled with fill {year: 1977, format: \"LP\"} and makes no outbound call; Phase 14 replaces the stub." - "Per D-12, the Albums toolbar declares buttons [create, delete, discogsSync]; the discogsSync stub answers golem15.fonoteka::lang.discogs.stub_not_implemented and changes nothing; both Discogs actions require the existing Winter permission golem15.fonoteka.access_albums, so a limited admin gets 403." - "Per D-13 to D-16, the controller declares assets/js/discogs-lookup.js and assets/css/albums.css through AdminJS/AdminCSS, both embedded in the plugin AdminFS and served under /plytadmin/assets/golem15/fonoteka/ with a JavaScript or CSS content type; the element is plain JS with no import, no user-facing string, no network request and no cookie or storage access." - "UI consideration (populated S1 Albums stats strip): one .summer-stats card shows the total, then formats with count above zero in option order, then No shelf when above zero, each a 13px muted label under a 20px/600 value." @@ -113,7 +113,7 @@ Tests pinning the current Albums shape: admin_albums_test.go (`len(schema.Fields ## Planning notes - Spec-less probe fallback skipped: no requirement IDs were mapped for Phase 10.1 before this planning run; ADMIN-07 is introduced by it. Truths come from CONTEXT D-01..D-17 and the UI-SPEC "Application proof" rows. -- Discretion resolved per UI-SPEC and RESEARCH Open Questions 1 and 3: stats show the total, per-format counts and no-shelf; the widget fills `[year, format]`, which needs the new `year` field; action names `discogsLookup` and `discogsSync`; both reuse `golem15.fonoteka.access_albums` (Pitfall 12, no Winter catalog divergence); the widget renders on create and update. +- Discretion resolved per UI-SPEC and RESEARCH Open Questions 1 and 3: stats show the total, per-format counts and no-shelf; the widget fills `[year, format]`, which needs the new `year` field (D-19); action names `discogsLookup` and `discogsSync`; both reuse `golem15.fonoteka.access_albums` (Pitfall 12, no Winter catalog divergence); the widget renders on create and update. - Albums has no real form partial; the form `type: partial` path is proven by the acme fixture in 10.1-01 (D-03 discretion). - Tests here are smoke tests; the full Albums acceptance test is 10.1-04 (CLAUDE.md rule 3). diff --git a/.planning/phases/10.1-runtime-admin-extension-point/10.1-CONTEXT.md b/.planning/phases/10.1-runtime-admin-extension-point/10.1-CONTEXT.md index 4ba98ab..9ba7acc 100644 --- a/.planning/phases/10.1-runtime-admin-extension-point/10.1-CONTEXT.md +++ b/.planning/phases/10.1-runtime-admin-extension-point/10.1-CONTEXT.md @@ -48,6 +48,10 @@ Not in scope: the real Discogs HTTP client and jobs (Phase 14); WASM (FW-06 / v2 ### Hygiene constraint (partial HTML in the SPA) - **D-17:** Phase 10 forbids unsanitized `v-html` / `innerHTML` (T-10-16, `--hygiene`). Partial HTML still has to appear in the list/form. Researcher/planner must pick a host that does not reopen that threat (dedicated sanitized slot, iframe under the admin prefix, or equivalent). Record data must not become executable HTML. +### Resolved at planning (2026-09-28) +- **D-18:** `golang.org/x/net/html` becomes a direct dependency of the framework, used only by the cabana partial sanitizer (`html.ParseFragment` + tag/attribute allowlist). It is already in `go.sum` as an indirect dependency, so no new module enters the build. This is the decision note CLAUDE.md rule 4 asks for; `encoding/xml` was rejected as too weak on real HTML. — **Reversibility:** reversible +- **D-19:** The Albums Discogs widget uses `fill: [year, format]`. A `year` field (`type: number`) is added to the Albums admin form; the model already has `Year *int`. The `format`-only option was rejected. — **Reversibility:** reversible + ### Claude's Discretion - Exact YAML key names (`headerPartial` vs another spelling, widget `path` vs `tag`, action path convention). - JS/CSS capability interface name (must not reuse `pact.AdminAssets`). diff --git a/.planning/phases/10.1-runtime-admin-extension-point/10.1-PATTERNS.md b/.planning/phases/10.1-runtime-admin-extension-point/10.1-PATTERNS.md new file mode 100644 index 0000000..39ab358 --- /dev/null +++ b/.planning/phases/10.1-runtime-admin-extension-point/10.1-PATTERNS.md @@ -0,0 +1,555 @@ +# Phase 10.1: Runtime admin extension point - Pattern Map + +**Mapped:** 2026-09-28 +**Files analyzed:** 38 (new + modified, both repos) +**Analogs found:** 35 / 38 + +All analog paths below are git-tracked (verified with `git ls-files` in each repo). Line numbers are from the tree at commit b2845e0 (summercms.go) and the current fonoteka.go HEAD. + +## File Classification + +### Plan 01 — framework Go (`summercms.go`) + +| New/Modified File | Role | Data Flow | Closest Analog | Match Quality | +|---|---|---|---|---| +| `modules/pact/capabilities.go` (mod) | contract/interface | n/a | same file: `AdminAssets` 115-119, `SettingsItem` func field 163-175, `FormExtendQuery` 205-208 | exact | +| `modules/cabana/form_schema.go` (mod) | config compiler | transform (YAML → schema) | same file `compileFieldNode` 321-389, `formFieldKeys` 33-37 | exact | +| `modules/cabana/list_schema.go` (mod) | config compiler | transform | same file `toolbarButtons.UnmarshalYAML` 291-320, `compileToolbarButtons` 322-333, `listDocument` 30-42 | exact | +| `modules/cabana/registry.go` (mod) | boot wiring | batch (boot) | same file `compileRegistry` 53-98, `compileContributions` 173-192 | exact | +| `modules/cabana/extension.go` (new) | boot validator | batch (boot) | `registry.go` `compileContributions` + `validatePermissions` 212-219; `form_schema.go` `requireDropdownProvider` 186-217 | role-match | +| `modules/cabana/actions.go` (new) | controller (HTTP handler) | request-response (POST) | `http.go` `relationMutation` 442-471 + `decodeRelationMutation` 473-486; `crud.go` `loadRecord` 435-455 | exact | +| `modules/cabana/partial_render.go` (new) | service (render + sanitize) | transform | `form_schema.go` `Localize`/`translateKey` 126-160, 244-249 (per-request translation); no HTML sanitizer exists | partial | +| `modules/cabana/plugin_assets.go` (new) | static file handler | file-I/O (embed.FS) | `modules/boardwalk/boardwalk.go` `serveFile` 135-148, `contentType` 150-159, `setSecurityHeaders` 161-167 | exact | +| `modules/boardwalk/boardwalk.go` (mod: export `ContentType`, `SetSecurityHeaders`) | utility | n/a | same file 150-167 | exact | +| `modules/cabana/http.go` (mod: mount 3 API + 1 asset route) | route | request-response | same file `mount` 185-245, `constrainRelation` 253-256 | exact | +| `modules/cabana/schema_types.go` (mod: `ControllerAssets`, `ToolbarAction`, `HeaderPartial` on ListSchema/FormView, `PartialNode`, `PartialView`, `AdminActionRequest`) | model (DTO) | n/a | existing `ListSchema`/`FormView` types in same file | exact | +| `modules/cabana/admin_openapi.go` (mod) | config (swag annotations) | n/a | same file `AdminBulkDelete` 370-388 | exact | +| `modules/cabana/crud.go` (mod, only if a non-locking scoped read helper is added) | service | CRUD | `loadRecord` 435-455 (copy minus `clause.Locking`) | exact | +| `modules/phrasebook/backend/lang/{en,pl}/lang.yaml` (mod: `extension:` group) | config (i18n) | n/a | same file `form:` group (line 54, `unsupported_field` 75) | exact | +| `modules/cabana/README.md` (mod) | docs | n/a | same file (CLAUDE.md doc rule) | exact | +| `admin/openapi/admin.json`, `admin/src/api/schema.d.ts` (regen) | generated | n/a | `scripts/check-admin-openapi.sh` output | exact | +| `go.mod` (promote `golang.org/x/net` to direct) | config | n/a | — | n/a | + +### Plan 02 — framework SPA (`summercms.go/admin`) + +| New/Modified File | Role | Data Flow | Closest Analog | Match Quality | +|---|---|---|---|---| +| `admin/src/app/pluginAssets.ts` (new) | utility (loader) | event-driven (DOM load/error) | `admin/src/app/runtime.ts` (module-level singleton, `runtime.base`) | partial | +| `admin/src/components/form/fields/WidgetField.vue` (new) | component | event-driven + request-response | `fields/UnsupportedField.vue` (failure-box geometry), `views/ListView.vue` `onDelete` 184-212 (POST → toast) | role-match | +| `admin/src/components/form/fields/PartialField.vue` (new) | component | request-response (GET) | `fields/UnsupportedField.vue` + `FieldControlProps` in `control.ts` 7-21 | role-match | +| `admin/src/components/partial/PartialHost.vue` (new) | component (h() renderer) | transform | `components/form/control.ts` `allowedAttributes`/`controlAttributes` 25-50 (client allowlist idiom) | partial | +| `admin/src/components/form/formContext.ts` (new) | provider (InjectionKey) | n/a | `components/form/control.ts` (shared-types module split from registry to avoid cycles) | partial | +| `admin/src/components/form/registry.ts` (mod) | registry | n/a | same file 27-59 | exact | +| `admin/src/components/form/formState.ts` (mod, only via registry `isRegistered`) | utility | transform | same file `editablePayload` 46-58 | exact | +| `admin/src/components/form/FormField.vue` (mod: span label + role=group for widget/partial) | component | n/a | same file (`ownsLabel` branch) | exact | +| `admin/src/components/list/ListToolbar.vue` (mod) | component | event-driven | same file 1-58 (delete button loop 236-247 of template) | exact | +| `admin/src/views/ListView.vue` (mod) | view | request-response | same file 66-68 (button split), 184-212 (`onDelete`), 216-247 (template) | exact | +| `admin/src/views/FormView.vue` (mod: provide values/patch, load assets) | view | request-response | same file 131-160 (`dirty`, `load`) | exact | +| `admin/src/api/types.ts` (mod: `Schemas['cabana.X']` aliases) | model (types) | n/a | existing aliases (gate regex `check-phase10.sh:330`) | exact | +| `admin/src/styles/main.css` (mod: `.summer-partial`, `.summer-stats` kit in `@layer components`) | config (CSS) | n/a | existing `@layer components` in same file | exact | +| `admin/vite.config.ts` (mod: `${devPrefix}/assets` proxy) | config | n/a | existing `${devPrefix}/api` proxy entry | exact | +| `modules/boardwalk/dist/**` (rebuild) | generated | n/a | `scripts/check-admin-dist.sh` | exact | + +### Plan 03 — application (`fonoteka.go/plugins/golem15/fonoteka`) + +| New/Modified File | Role | Data Flow | Closest Analog | Match Quality | +|---|---|---|---|---| +| `controllers/albums_admin_controller.go` (mod: `AdminJS/AdminCSS`, `AdminActions`, `PartialData`) | controller | CRUD (read counts) + request-response | same file: `DropdownOptions` 42-53, `scopeAlbums` 99-108, `orderedOptions` 149-169 | exact | +| `controllers/albums/config_list.yaml` (mod) | config | n/a | same file 1-19 | exact | +| `controllers/albums/_stats.htm` (new) | template | transform | UI-SPEC recommended `
` markup | no code analog | +| `models/album/fields.yaml` (mod: `year`, `discogs` widget) | config | n/a | same file 1-28 | exact | +| `assets/js/discogs-lookup.js` (new) | component (custom element) | event-driven | RESEARCH "Plugin custom element" example | no code analog | +| `assets/css/albums.css` (new) | config (CSS) | n/a | UI-SPEC S3 visual contract | no code analog | +| `admin.go` (mod: extend `//go:embed` list) | config | file-I/O | same file line 12 | exact | +| `lang/{en,pl}/lang.yaml` (mod: `discogs.*`, `stats.*`, `item.year`) | config (i18n) | n/a | same files, `discogs:` group line 197, `album_format:` 161 | exact | +| `admin_albums_test.go`, `admin_phase10_copy_test.go`, `admin_phase10_controllers_test.go` (mod) | test | n/a | same files (Pitfall 3: lines 132, 357 / 48 / 100) | exact | + +### Plan 04 — unit tests + gate + evidence + +| New/Modified File | Role | Data Flow | Closest Analog | Match Quality | +|---|---|---|---|---| +| `modules/cabana/testdata/extension/**` (acme fixture tree) | test fixture | n/a | `openapi_conformance_test.go` `conformPlugin`/`conformController`/`conformFS` 365-440 | exact | +| `modules/cabana/phase101_*_test.go` (schema, toolbar, sanitizer, assets, actions) | test | n/a | `form_schema_test.go` table 215-285; `openapi_conformance_test.go` 110-135 | exact | +| `modules/cabana/form_schema_test.go`, `list_schema_test.go` (mod) | test | n/a | same files (partial cases 224-238, "bad form fails activation" 273-284) | exact | +| `modules/cabana/security_coverage_test.go` (mod: `phase09Routes`) | test (inventory) | n/a | same file 33-62, handler table ~266 | exact | +| `modules/cabana/openapi_conformance_test.go` (mod) | test | request-response | same file 118-130 | exact | +| `admin/tests/app/pluginAssets.test.ts`, `tests/form/WidgetField.test.ts`, `tests/form/PartialField.test.ts`, `tests/list/PartialHost.test.ts` (new); `tests/list/ListToolbar.test.ts`, `ListView.test.ts`, `tests/form/registry.test.ts`, `formState.test.ts` (mod) | test | n/a | `admin/tests/list/ListToolbar.test.ts` 1-40 | exact | +| `admin/tests/fixtures/extension.*.json` (new) | fixture | n/a | `admin/tests/fixtures/widgets.list-schema.json`, `widgets.form-schema.json` | exact | +| `fonoteka.go/.../admin_phase101_albums_test.go` (new) | test (Postgres) | CRUD | `admin_albums_test.go` (`TestAlbumsAdminForm` line 28) | exact | +| `scripts/check-phase10.1.sh` (new) | gate script | batch | `scripts/check-phase10.sh` (1-60 header/`phase10_detect`, 361-420 hygiene/evidence/dispatch); newest sibling `scripts/check-phase10.2.sh` | exact | +| `.planning/phases/10.1-.../10.1-SECURITY-REVIEW.md`, `10.1-VALIDATION.md` | docs | n/a | `.planning/phases/10-admin-vue-spa/10-SECURITY-REVIEW.md` | exact | + +--- + +## Pattern Assignments + +### `modules/pact/capabilities.go` (contract) + +**Analog:** same file. Optional interfaces are one-method, doc-commented, type-asserted by cabana. + +Lines 115-130: +```go +// AdminAssets is the plugin-owned embedded tree of Winter admin YAML. +// Paths are relative to the plugin root (controllers/..., models/...). +type AdminAssets interface { + AdminFS() fs.FS +} + +// AdminPermissioned is the D-03 permission list enforced before schema or SQL. +type AdminPermissioned interface { + RequiredPermissions() []string +} +``` +Func-field-in-struct precedent (lines 163-175): `Form string \`json:"-"\`` / `NewModel func() any \`json:"-"\``. Give `AdminAction.Run` a `json:"-"` tag the same way. Context-taking hook shape (205-208): +```go +type FormExtendQuery interface { + FormExtendQuery(ctx context.Context, db *gorm.DB) *gorm.DB +} +``` +Add `AdminClientAssets`, `AdminAction`, `AdminActionInput`, `AdminActionResult`, `HasAdminActions`, `AdminPartialData` (RESEARCH Pattern 1) right after `AdminRecordSource`. Update `modules/pact/README.md` API reference in the same commit. + +--- + +### `modules/cabana/form_schema.go` (config compiler) + +**Analog:** same file. + +Key allowlist to extend (33-37): add `"widget"`, `"action"`, `"fill"`, `"path"` to `formFieldKeys`; add `"widget"`, `"partial"` to `formFieldTypes` (23-26). + +Rejection to replace (344-349): +```go +if typ == "partial" { + return FormField{}, fmt.Errorf("type partial is not supported") +} +if _, ok := formFieldTypes[typ]; !ok { + return FormField{}, fmt.Errorf("unsupported type %s", typ) +} +``` +Per-key decode idiom to copy for `widget`/`action`/`path` (351-356): +```go +if node, ok := values["label"]; ok { + field.Label, err = nodeString(node) + if err != nil { + return FormField{}, fmt.Errorf("label: %w", err) + } +} +``` +Key-on-wrong-type rule: after `typ` is known, reject `values["widget"|"action"|"fill"]` unless `typ == "widget"` and `values["path"]` unless `typ == "partial"`. Tag regex / `{vendor}-{plugin}-` prefix, fill ⊆ `cc.Writable` and template existence need the controller and plugin id, so they belong in `extension.go` (post-`BindWritableFields`), not in the decoder. `compileSetting` reuses `decodeFields`: reject `widget`/`partial` there (rule 9). + +Controller-capability boot check to copy (186-198, `requireDropdownProvider`): +```go +if method == "" || dropdownProvider(ctl) != nil { + return nil +} +return fmt.Errorf("dropdown method %s requires DropdownOptions", method) +``` +Localize (136-147): add `translateKey` for any new label-bearing fields; widget `label`/`busy-label` come from the action `Label` resolved here. + +--- + +### `modules/cabana/list_schema.go` (config compiler) + +**Analog:** same file. + +Add `HeaderPartial string \`yaml:"headerPartial"\`` to `listDocument` (30-42) — `decodeStrict` rejects unknown keys automatically. + +Decode-time check that must move (Pitfall 1), lines 288-310: +```go +// toolbarActions are the built-in toolbar actions; custom actions are Phase 10.1. +var toolbarActions = map[string]struct{}{"create": {}, "delete": {}} +... + if _, ok := toolbarActions[action]; !ok { + return fmt.Errorf("toolbar.buttons: unsupported action %s (want create or delete)", action) + } +``` +Keep `nodeString`/identifier + duplicate checks in `UnmarshalYAML`; drop the membership test. Resolve membership in `compileToolbarButtons` (322-333), which gains the controller: +```go +func compileToolbarButtons(toolbar *listToolbar, showCheckboxes bool) ([]string, error) { + ... + for _, action := range out { + if action == "delete" && !showCheckboxes { + return nil, fmt.Errorf("toolbar.buttons: delete needs showCheckboxes: true") + } + } +``` +Call site (128-131) wraps errors with `bootErr(pluginID, ctl.ID(), cfgPath, err)` — keep that. Note `registry.go` 80-83 strips `create` via `withoutAction`; custom names must survive it. + +--- + +### `modules/cabana/registry.go` + `modules/cabana/extension.go` (boot) + +**Analog:** `compileRegistry` 53-98 (per-controller compile then `BindWritableFields`), `compileContributions` 173-192 (permission validation after all plugins' permissions are known). + +```go + compiled := &CompiledController{ ... } + if err := BindWritableFields(compiled); err != nil { + return nil, err + } + byID[id] = compiled +``` +Insert `compileExtension(item.plugin.ID(), compiled, assets.AdminFS())` after `BindWritableFields` (fill ⊆ `cc.Writable`, widget tag, action registered, partial templates parse, asset files read + hashed). Store results on `CompiledController` (new fields: `Actions map[string]pact.AdminAction`, `Partials map[string]*compiledPartial`, `Assets ControllerAssets`). + +Action permissions go next to relation permissions (177-181): +```go + for name, relation := range controller.Relations { + if err := reg.validatePermissions("relation "+id+"."+name, relation.RequiredPermissions); err != nil { + return err + } + } +``` +→ `reg.validatePermissions("action "+id+"."+name, action.Permissions)`. Reserved `assets` segment already exists (line 40), no change. + +--- + +### `modules/cabana/actions.go` (HTTP handlers, POST) + +**Analog:** `http.go` `relationMutation` 442-471 and `decodeRelationMutation` 473-486. + +Handler shape: +```go +func (s *service) relationMutation(w http.ResponseWriter, r *http.Request, link bool) { + s.protect(w, r, func(cc *CompiledController) { + ... + in, err := decodeRelationMutation(r) + if err != nil { + writeCRUDError(w, err) + return + } + svc, err := s.relations() + if err != nil { + WriteError(w, http.StatusInternalServerError, "error", msgServerError) + return + } + ... + if err != nil { + writeCRUDError(w, err) + return + } + WriteData(w, http.StatusOK, result, nil) + }) +} +``` +Strict body decode to copy verbatim for `AdminActionRequest`: +```go + dec := json.NewDecoder(r.Body) + dec.UseNumber() + dec.DisallowUnknownFields() + var in RelationMutationInput + if err := dec.Decode(&in); err != nil { + return RelationMutationInput{}, relationInvalid("body", "The request body is invalid.") + } + var trailing any + if err := dec.Decode(&trailing); err != io.EOF { + return RelationMutationInput{}, relationInvalid("body", "The request body is invalid.") + } +``` +Action-level permission (403) after `protect` — copy from `protect` 713-717: +```go + if !Allows(principal, requiredOf(cc.Controller)) { + s.logAuth(r, "denied", principal.ID) + WriteError(w, http.StatusForbidden, "forbidden", msgForbidden) + return + } +``` +Scoped record read: copy `crud.go` `loadRecord` 435-455 **without** `.Clauses(clause.Locking{Strength: "UPDATE"})`: +```go + q := tx.WithContext(ctx) + if cc != nil { + if ext, ok := cc.Controller.(pact.FormExtendQuery); ok && ext != nil { + if next := ext.FormExtendQuery(ctx, q); next != nil { + q = next + } + } + } + err := q.Where(clause.Eq{Column: clause.Column{Name: col}, Value: pk}).Take(dest).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return recordNotFound{} + } +``` +Error mapping: `writeCRUDError` (crud.go 387-404) gives 422/404/500. Name the partial type away from `partialSelection` (crud.go 398, Pitfall 13). + +--- + +### `modules/cabana/http.go` (route mount) + +**Analog:** `mount` 198-245. +```go + r.GroupRaw(api, []string{"backend"}, func(g pact.Router) { + ... + g.Post("/{vendor}/{plugin}/{controller}/bulk-delete", requireAjax(s.bulkDelete)) + constrainController(g) + ... + r.GroupRaw(s.adminPrefix(), nil, func(g pact.Router) { + g.Get("", s.serveSPA) + g.Get("/{path...}", s.serveSPA) +``` +Add a `constrainAction(g)` helper modelled on `constrainRelation` (253-256): +```go +func constrainRelation(g pact.Router) { + constrainController(g) + g.Where("name", "[A-Za-z_][A-Za-z0-9_]*") +} +``` +Mount the asset route **before** `/{path...}` in the prefix group; on allowlist miss call `s.serveSPA(w, r)` (157-163). + +--- + +### `modules/cabana/plugin_assets.go` (static serving) + +**Analog:** `modules/boardwalk/boardwalk.go` 135-167. +```go +func (h *handler) serveFile(w http.ResponseWriter, r *http.Request, name string) { + body, err := fs.ReadFile(h.root, name) + ... + w.Header().Set("Content-Type", contentType(name)) + if strings.HasPrefix(name, "assets/") { + w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") + } else { + w.Header().Set("Cache-Control", "no-cache") + } + http.ServeContent(w, r, path.Base(name), time.Time{}, bytes.NewReader(body)) +} + +func setSecurityHeaders(h http.Header) { + h.Set("X-Content-Type-Options", "nosniff") + h.Set("Referrer-Policy", "same-origin") + h.Set("X-Frame-Options", "DENY") + h.Set("Content-Security-Policy", contentSecurityPolicy) + h.Set("X-Robots-Tag", "noindex, nofollow") +} +``` +Export `ContentType` and `SetSecurityHeaders` from boardwalk (update `modules/boardwalk/README.md`). Plugin handler: always `no-cache` + `ETag` (never the `immutable` branch), add `Cross-Origin-Resource-Policy: same-origin`, body from the boot-built map, not `fs.ReadFile` per request. + +--- + +### `modules/cabana/partial_render.go` (render + sanitize) + +**Analog (partial):** per-request translation idiom from `form_schema.go` 244-249: +```go +func translateKey(ctx context.Context, tr *phrasebook.Translator, key string) string { + if key == "" || tr == nil { + return key + } + return tr.Get(ctx, key, nil) +} +``` +and `s.translator()` (http.go 534-543). The Clone/Funcs/ParseFragment/allowlist core has no codebase analog — use RESEARCH "Partial render + allowlist (sketch)" and Pattern 4 allowlist verbatim. Handler for `GET …/partials/{name}` follows `formSchema` (496-513): `protect` → lookup → `WriteData(w, 200, view, nil)`. + +--- + +### `modules/cabana/admin_openapi.go` (swag) + +**Analog:** lines 370-388 (`AdminBulkDelete`). Copy the block per route; POSTs keep `@Accept json`, `@Param body body AdminActionRequest true "..."`, `@Success 200 {object} Envelope[AdminActionResult]`, 401/403/404/422 failures. GET partial: `@Param id query integer false "Record id"`, `@Success 200 {object} Envelope[PartialView]`. Regenerate with `scripts/check-admin-openapi.sh`. + +--- + +### `admin/src/components/form/registry.ts` + +**Analog:** same file 27-59. +```ts +const recordBound = new Set([RELATION_MANAGER]) + +export function isRegistered(type: string): boolean { + return renderers.has(type) && !recordBound.has(type) +} +export function needsRecord(type: string): boolean { + return recordBound.has(type) +} +``` +Add `['widget', WidgetField]`, `['partial', PartialField]` to `renderers`; add `const valueless = new Set([RELATION_MANAGER, 'widget', 'partial'])` and switch `isRegistered` to it; leave `needsRecord` on `recordBound` (Pitfall 4). `editablePayload` (formState.ts 46-58) then skips them with no change. New field components import `../control`, never `../registry` (cycle note, lines 18-20). + +--- + +### `admin/src/components/form/fields/WidgetField.vue` / `PartialField.vue` + +**Analog:** `fields/UnsupportedField.vue` (props + failure box): +```vue +const props = defineProps() +... +
+