Files
summercms/.planning/phases/10.1-runtime-admin-extension-point/10.1-RESEARCH.md
Jakub Zych 9b98d8409f 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.
2026-09-28 22:44:34 +02:00

67 KiB
Raw Blame History

Phase 10.1: Runtime admin extension point - Research

Researched: 2026-09-28 Domain: Go admin schema pipeline (cabana), embedded asset serving (boardwalk), Vue 3 SPA extension seams, html/template partials, custom elements Confidence: HIGH for codebase facts and the Go/OpenAPI toolchain (read or probed this session); MEDIUM for browser-behaviour claims that can't be exercised in happy-dom

<user_constraints>

User Constraints (from CONTEXT.md)

Locked Decisions

Acceptance

  • D-01: Prove the phase on Płytarium Albums, not a toy-only milestone: stats strip on the Albums list, Discogs widget on the Albums form, one extra Albums toolbar button. A nameless fixture plugin in summercms.go still covers the framework contract (no Płytarium names in the framework repo).
  • D-02: The Discogs widget button is enabled in this phase. Click hits a stub endpoint that returns a clear not-implemented or fixture payload. Phase 14 replaces the stub. Do not implement the Discogs client here.
  • D-03: The statistics strip is list chrome above DataTable, not type: partial in fields.yaml. Form type: partial is still in this phase (boot must allow it); Płytarium may not have a form partial yet — the fixture plugin (or a tiny unused field) proves that path.

Widget contract

  • D-04: Plugins ship plain JS custom elements. No Vue SFC, no Vue compiler, no import of admin SPA modules. App developers never need Node (Phase 10 D-04 stands).
  • D-05: The SPA owns HTTP. The widget dispatches a CustomEvent; FieldRenderer POSTs to the YAML-declared action with the existing cookie and X-Requested-With CSRF header. Widget JS must not fetch the admin API and must not read the JWT cookie. — Reversibility: costly — every widget and the CSRF matrix assume this split.
  • D-06: fields.yaml uses Winter-shaped type: widget plus a small key set: custom-element tag/path, POST action, and fill: [keys] for write-back. Unknown keys fail boot (DisallowUnknownField). — Reversibility: costly — every ported fields.yaml that uses a widget writes this shape.
  • D-07: On success the SPA patches only the fill keys onto the form model. Phase 10.1 may return a fixture payload so the save path is real; Phase 14 returns Discogs values into the same keys.
  • D-08: The SPA mounts the custom element with record-id, field name, locale, and a snapshot of the current fill values. No Vue instance, no token, no cookie on the element.

Partials and toolbar

  • D-09: Both surfaces ship: list-header partials and form type: partial. Phase 9’s boot error for type: partial is lifted for a supported partial contract. type: widget is added to the allowed form types.
  • D-10: Partials render with html/template against a curated view model the controller supplies. The template must not receive a raw GORM model or the request. html/template escaping stays on; record fields are not trusted HTML.
  • D-11: The Albums stats strip is declared in config_list.yaml (e.g. headerPartial: naming the template). The controller implements the view-model method. Missing template or unknown YAML key fails boot.
  • D-12: Custom toolbar actions extend the D-14 string list: toolbar.buttons: [create, delete, discogsSync]. Unknown names fail boot unless the controller registers that action (label, permission, POST path). Click: SPA POSTs with CSRF, stub returns, toast. create/delete behavior is unchanged. — Reversibility: costly — list YAML and the toolbar compiler grow a registration table.

Assets, serving, CSP

  • D-13: Controllers declare JS/CSS with a Go method (Winter addJs/addCss). pact.AdminAssets remains the YAML embed.FS — the JS/CSS interface gets a different name (planner). Files live in the plugin embed tree.
  • D-14: Assets load when that controller opens (list or form). Other plugins’ admin JS stay unloaded.
  • D-15: Production and dev both serve from embed.FS. No disk-override switch in v1. air / summer watch rebuilds the binary. — Reversibility: reversible
  • D-16: Files are served under the admin prefix, same origin as the SPA, e.g. {backend.uri}/assets/{vendor}/{plugin}/…. CSP stays script-src 'self': no unsafe-inline, no extra script hosts. Cookie stays HttpOnly, Secure, SameSite=Strict (Phase 10 D-19). — Reversibility: costly — CSP hygiene gate and cookie threat model (T-10-01, T-10-16) assume this.

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.

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).
  • Stub JSON envelope, toast copy, and the fixture fill payload for the Discogs widget.
  • Which numbers the Albums stats strip shows (counts the controller already has vs a small new query).
  • How the form type: partial proof is wired if fonoteka has no real form partial (fixture plugin field vs a harmless unused Albums field).
  • Custom-element tag naming and customElements.define timing relative to asset load.
  • Whether the Discogs widget renders on create, update, or both (record-id may be empty on create).
  • Exact asset URL layout under {backend.uri}/assets/.
  • Partial-host implementation that satisfies D-17.
  • Whether the third toolbar action is named discogsSync or something else; label/permission strings.

Deferred Ideas (OUT OF SCOPE)

  • Phase 14: Real Discogs client, jobs, and non-stub widget/toolbar payloads.
  • Dev-mode disk override for plugin JS/CSS — considered and rejected for v1 (embed only).
  • WASM sandboxed extension API — FW-06 / v2; not a substitute for this compiled-plugin extension point.
  • Phase 10 leftovers, still out of scope: Ctrl+K command palette, badge/icon column type, Playwright admin e2e, user/media admin navigation.
  • Reviewed todo not folded: backend admin personal API tokens (deferred Apparatus PersonalApiToken). </user_constraints>

<phase_requirements>

Phase Requirements

No requirement ID is mapped yet (ROADMAP says Requirements: TBD). CONTEXT asks for a new ID; ADMIN-06 is complete and must not be reused.

Proposed REQUIREMENTS.md entry (Admin section, after ADMIN-06):

  • ADMIN-07: A plugin extends the compiled admin SPA without a Node rebuild: controller-declared JS/CSS is served from the plugin's embedded files under {backend.uri}/assets/ and loaded when that controller opens (CSP script-src 'self'); type: widget fields mount plugin custom elements whose actions the SPA posts with the admin cookie and CSRF header, patching only the declared fill fields; type: partial form fields and a config_list.yaml headerPartial render server-side with html/template from a controller view model and display without any raw-HTML sink; and controllers register named toolbar actions. Unknown YAML keys, missing templates and unregistered actions fail boot.

Traceability row: | ADMIN-07 | Phase 10.1 | Pending |

ROADMAP §Phase 10.1 Requirements: ADMIN-07

ID Description Research Support
ADMIN-07 (proposed) Runtime admin extension point (assets, widgets, partials, toolbar actions) Patterns 1-7, Don't Hand-Roll, Pitfalls 1-14, Validation Architecture, Security Domain
</phase_requirements>

Summary

The Phase 9/10 pipeline already has all the seams this phase needs. They are closed shut in three places. cabana/form_schema.go explicitly rejects type: partial and has no widget. cabana/list_schema.go checks toolbar names against a static {create, delete} map during YAML decode, before any controller is visible. Surf refuses any plugin route under the admin prefix (TestPhase10AdminPrefixCollision). The last point is the decisive design constraint. A plugin cannot own the POST path for a widget or a toolbar action, so "controller registers an action" has to mean a Go handler function that cabana dispatches from cabana-owned routes: POST …/{controller}/widgets/{field} and POST …/{controller}/toolbar/{action}. That way requireAjax, protect(), permission checks and record scoping come from the framework for free. TestPhase10CSRF auto-walks every unsafe route service.mount registers, so the new routes are CSRF-tested without new test code.

For D-17, the recommended partial host is a server-sanitized node tree. cabana executes the html/template partial and parses the output with golang.org/x/net/html.ParseFragment. It walks that tree through a tag/attribute allowlist and returns JSON nodes ({tag, attrs, children} / {text}). The SPA builds DOM with Vue h(), re-checking the same allowlist, so no HTML string ever reaches a browser parser. That satisfies the existing --hygiene grep (v-html|innerHTML|outerHTML|insertAdjacentHTML) without an exemption, adds no npm package (T-10-SC), keeps the admin theme and dark mode, and is fully unit-testable in Go. The sandboxed iframe, the sanitized v-html slot and declarative shadow DOM were all evaluated and rejected (see Pattern 4). I verified end to end this session that swag v1.16.6, the repo's swagger2openapi and openapi-typescript 7.13.0 handle the recursive PartialNode type.

Assets are served by a cabana-mounted GET {prefix}/assets/{vendor}/{plugin}/{file...} route. It looks each file up in a boot-built exact allowlist of declared files. The route never exposes the plugin FS, so there is no path traversal and no YAML or template disclosure. Each response carries an explicit JS/CSS MIME type, nosniff, no-cache + ETag and a ?v=<hash> URL. The URL namespace is shared with Vite's flat dist/assets/*, so an allowlist miss must fall through to the SPA handler. The SPA loads assets idempotently: a module-level Map<url, Promise>, <script type="module">, and <link rel=stylesheet> toggled disabled per active controller. Widgets are created imperatively and wait on customElements.whenDefined.

Primary recommendation: Four lean plans: (1) framework Go (pact interfaces, cabana YAML/boot rules, action/partial/asset routes, sanitizer, OpenAPI regen, acme fixture); (2) framework SPA (loader, WidgetField, PartialHost, list-header slot, custom toolbar buttons, dist rebuild); (3) fonoteka.go Albums (stats strip, Discogs widget stub, discogsSync stub, assets, lang, fixed existing tests); (4) unit tests plus scripts/check-phase10.1.sh, security review and validation evidence (last plan, per CLAUDE.md rule 3). Present this count at the plan-count checkpoint (CLAUDE.md rule 2).

Architectural Responsibility Map

Capability Primary Tier Secondary Tier Rationale
YAML parsing/validation of widget, partial, headerPartial, toolbar names API / Backend (cabana boot) — Fail-loud boot is an established pattern; the SPA only renders what it receives (T-10-18)
Action dispatch (widget + toolbar POST), permission, scoping, CSRF API / Backend (cabana routes) Plugin Go handler (business logic) Plugins cannot mount routes under the prefix; cabana owns auth/CSRF/scope
Fill-key filtering API / Backend Browser (defence in depth) Server drops non-declared keys; SPA patches only field.fill keys
Partial rendering + sanitization API / Backend (html/template + x/net/html allowlist) Browser (h() allowlist re-check) Keeps HTML strings away from the DOM parser (D-17)
Partial view model Plugin controller (Go) — D-10 curated struct; cabana supplies the scoped record
Static JS/CSS serving Frontend server (cabana route, boardwalk-style headers) — Same origin, script-src 'self' (D-16)
Asset loading, custom-element mounting, event → POST Browser (admin SPA) — D-05: SPA owns HTTP
Widget UI (button, busy state) Browser (plugin custom element) — D-04: plain JS custom elements
Stats numbers Database via plugin controller — Must reuse the collection scope (scopeAlbums)

Project Constraints (from CLAUDE.md)

  • Lean planning: few, large plans. Before writing PLAN.md files, present the plan count with a one-line scope each and wait for confirmation.
  • Unit tests are always the last plan of the phase; earlier plans may carry smoke tests.
  • Go: stdlib first (net/http ServeMux, html/template, encoding/json). Add a dependency only when the research doc or a phase decision names it. This document names golang.org/x/net/html (already golang.org/x/net v0.58.0 // indirect in go.mod); the planner should record it as a phase decision.
  • go vet ./... and go test ./... green at every commit, in both repos.
  • Compiled plugins only; no runtime plugin loading (stdlib plugin, yaegi).
  • Two repositories: summercms.go is framework-only and must contain no Płytarium names (hygiene-enforced). fonoteka.go holds the Albums work. Planning docs stay in summercms.go/.planning.
  • Commits: no co-author tags; one logical change per commit; planning docs and code in separate commits.
  • Core plugin contracts (user, blog, pages, payment) are not changed. This phase touches none of them.
  • API parity is the acceptance test for Płytarium API routes. Admin routes are framework-owned and not part of the Nuxt parity contract.
  • Adding a permission code to fonoteka diverges from the Winter registerPermissions() catalog (admin_permissions.go "preserves the plugin's Winter registerPermissions() catalog"). Reuse golem15.fonoteka.access_albums for both stub actions.

Standard Stack

Core (all already in the tree; nothing new to install except promoting one indirect Go module)

Library Version Purpose Why Standard
html/template (stdlib) Go 1.27.0 Partial rendering with contextual autoescaping Locked by D-10; Clone() + Funcs() binds per-request trans [VERIFIED: go doc html/template Template.Clone — "It returns an error if t has already been executed." / Template.Funcs — "Funcs may be called more than once, including after parsing (for example, after Template.Clone), to replace a function of the same name"]
golang.org/x/net/html v0.58.0 (already // indirect in go.mod) Parse partial output into a node tree for the allowlist walk HTML5-spec tokenizer from the Go team. Promoting it adds no new module to go.sum [VERIFIED: summercms.go/go.mod golang.org/x/net v0.58.0 // indirect; go doc golang.org/x/net/html ParseFragment resolves from the module cache]
github.com/goccy/go-yaml v1.19.2 fields.yaml / config_list.yaml decode (existing) Existing; DisallowUnknownField path in decodeStrict
swag (via go run) v1.16.6 Admin OpenAPI annotations Existing pipeline. Recursive struct probed OK this session (self $ref emitted)
openapi-typescript 7.13.0 (admin devDependency, exact pin) TS types Existing. Probed this session: emits children?: components["schemas"]["…PartialNode"][]
Vue 3.5.35 (exact pin) h() render of the node tree, provide/inject for form values Existing
happy-dom 20.11.6 Vitest DOM Probed this session: supports customElements.define/whenDefined, bubbling/composed CustomEvent, and fires error on an unreachable <script src>

Supporting

Library Version Purpose When to Use
crypto/sha256 (stdlib) — Asset ETag and ?v= cache-buster At boot, per declared asset
testing/fstest, os.DirFS — acme fixture plugin trees Framework contract tests
testcontainers-go v0.44.0 (existing) Postgres for conformance and fonoteka acceptance --postgres-style tests

Alternatives Considered

Instead of Could Use Tradeoff
x/net/html stdlib encoding/xml (Strict=false, AutoClose=xml.HTMLAutoClose, Entity=xml.HTMLEntity) Zero dependency change, but it misparses ordinary HTML (unclosed <li>/<p>, void elements outside the list). Security is unaffected because we render our own tree, but plugin authors get surprising layouts. Use only if the user refuses to promote x/net
Server node tree sandboxed iframe / DOMPurify + v-html / declarative shadow DOM All rejected; see Pattern 4
Two cabana action routes one …/actions/{action} route One route cannot tell which widget's fill list applies, and cannot stop a toolbar-only action being called as a widget (or the reverse)

Installation: none. go mod tidy promotes golang.org/x/net to a direct requirement once modules/cabana imports golang.org/x/net/html. No npm changes (T-10-SC exact-pin gate stays untouched).

Package Legitimacy Audit

Package Registry Age Downloads Source Repo Verdict Disposition
golang.org/x/net (html subpackage) Go module proxy 10+ yrs n/a (Go team module, already in go.sum as indirect) go.googlesource.com/net OK (not run through the seam: it supports npm/pypi/crates only; module already pinned in go.sum) Approved; record as a phase decision

Packages removed due to [SLOP] verdict: none Packages flagged as suspicious [SUS]: none npm packages added: none (DOMPurify explicitly rejected)

Architecture Patterns

System Architecture Diagram

                         Browser (admin SPA, origin = app, CSP script-src 'self')
  ┌───────────────────────────────────────────────────────────────────────────────────────┐
  │ ListView / FormView                                                                   │
  │   │ GET schema/list | schema/form ──► schema.assets {scripts[], styles[]}             │
  │   ▼                                                                                   │
  │ pluginAssets.load(assets) ──(URL must start with runtime.base+'/assets/')──┐          │
  │   │  Map<url,Promise>; <script type=module>; <link rel=stylesheet>         │          │
  │   ▼                                                                        │          │
  │ WidgetField (type: widget)                                                 │          │
  │   whenDefined(tag, timeout) → createElement(tag) → setAttribute(record-id, │          │
  │   field-name, locale, fill-values JSON) → listen 'summer-action'           │          │
  │   │ event                                                                  │          │
  │   ▼                                                                        │          │
  │   api.POST …/widgets/{field} {record_id, values}  ─────────────────────┐   │          │
  │   ◄── {data:{message, fill}} → patch ONLY field.fill keys → toast      │   │          │
  │ ListToolbar custom button → api.POST …/toolbar/{action} → toast        │   │          │
  │ PartialHost (headerPartial | type: partial)                            │   │          │
  │   api.GET …/partials/{name}?id= → nodes[] → h() with allowlist         │   │          │
  └────────────────────────────────────────────────────────────────────────┼───┼──────────┘
                                                                           │   │ GET /backend/assets/{v}/{p}/{file}?v=hash
  Go binary ──────────────────────────────────────────────────────────────┼───┼───────────
  surf ServeMux (plugins refused under prefix)                             │   ▼
   ├─ cabana API group (backend guard) ◄───────────────────────────────────┘  cabana asset route (public)
   │    requireAjax (unsafe) → protect(controller perms) → action perms        exact allowlist map lookup
   │    → scoped record load (FormExtendQuery) → 404 if out of scope            hit: MIME + nosniff + ETag + no-cache
   │    → pact action Run(ctx, input) ──► plugin Go (stub now, Phase 14 real)   miss: fall through to boardwalk SPA
   │    → filter result.fill ⊆ field.fill → D-10 envelope
   │    partials: PartialData(ctx,name,record) → view model
   │      → html/template Clone+Funcs(trans) → Execute (size cap)
   │      → x/net/html ParseFragment → allowlist walk (node/depth caps) → JSON nodes
   └─ boardwalk SPA shell + dist/assets/* (immutable)                        plugin AdminFS (embed.FS)
summercms.go/
├── modules/pact/capabilities.go          # + AdminClientAssets, AdminAction(+Input/Result), HasAdminActions, AdminPartialData
├── modules/cabana/
│   ├── form_schema.go                    # widget/partial types + keys (widget, action, fill, path)
│   ├── list_schema.go                    # headerPartial; toolbar validation moved after decode
│   ├── extension.go                      # NEW: boot validation of widgets/partials/actions/assets (needs Writable)
│   ├── partial_render.go                 # NEW: template compile, render, x/net/html allowlist → []PartialNode
│   ├── plugin_assets.go                  # NEW: allowlist build + GET handler
│   ├── actions.go                        # NEW: widget + toolbar POST handlers
│   ├── http.go                           # mount the 3 API routes + asset route
│   ├── admin_openapi.go                  # annotations for the 3 API routes + new types
│   └── testdata/extension/               # NEW acme fixture tree (yaml, _*.htm, assets/js, assets/css)
├── modules/phrasebook/backend/lang/{en,pl}/lang.yaml  # widget/partial failure strings
├── admin/src/app/pluginAssets.ts         # NEW loader
├── admin/src/components/form/fields/WidgetField.vue    # NEW
├── admin/src/components/form/fields/PartialField.vue   # NEW (wraps PartialHost)
├── admin/src/components/form/formContext.ts            # NEW provide/inject keys (values + patch)
├── admin/src/components/partial/PartialHost.vue        # NEW h()-renderer + allowlist
├── admin/src/components/list/ListToolbar.vue           # custom action buttons
├── admin/src/views/ListView.vue / FormView.vue         # header slot, asset load, provide
├── admin/vite.config.ts                  # proxy `${devPrefix}/assets` in dev
└── scripts/check-phase10.1.sh            # NEW gate (last plan)

fonoteka.go/plugins/golem15/fonoteka/
├── assets/js/discogs-lookup.js           # <golem15-fonoteka-discogs-lookup>
├── assets/css/albums.css                 # scoped under the tag / .golem15-fonoteka-* classes
├── controllers/albums/_stats.htm         # header partial
├── controllers/albums/config_list.yaml   # headerPartial: stats; buttons: [create, delete, discogsSync]
├── models/album/fields.yaml              # discogs: {type: widget, …}
├── controllers/albums_admin_controller.go# AdminClientAssets, AdminActions, PartialData
├── admin.go                              # extend //go:embed list
└── lang/{en,pl}/lang.yaml                # action label, toast copy, stats labels

Pattern 1: Controller capability interfaces (pact)

What: Three optional interfaces, type-asserted by cabana like every Phase 9/10 hook. None reuses AdminAssets (D-13).

// pact/capabilities.go (proposed)

// AdminClientAssets is Winter's addJs/addCss for one admin controller (D-13).
// Paths are relative to the owning plugin's AdminFS and must live under assets/.
type AdminClientAssets interface {
	AdminJS() []string
	AdminCSS() []string
}

// AdminAction is one controller action a toolbar button or a widget runs (D-12, D-05).
type AdminAction struct {
	Name        string   // identifier; toolbar.buttons entry or a widget's action:
	Label       string   // phrase key; toolbar button text
	Permissions []string // checked in addition to the controller's RequiredPermissions
	Run         func(ctx context.Context, in AdminActionInput) (AdminActionResult, error)
}

type AdminActionInput struct {
	Field    string         // widget field name; "" for a toolbar action
	RecordID *uint64        // nil on create and for toolbar actions
	Record   any            // record loaded through FormExtendQuery; nil when RecordID is nil
	Values   map[string]any // widget fill snapshot, already filtered to the field's fill keys
}

type AdminActionResult struct {
	Message string         // phrase key or text; localized by cabana
	Fill    map[string]any // widget write-back; keys outside the field's fill are dropped
}

type HasAdminActions interface{ AdminActions() []AdminAction }

// AdminPartialData supplies the curated view model a controller partial renders (D-10).
// record is the scoped record for a form partial on an existing record, else nil.
type AdminPartialData interface {
	PartialData(ctx context.Context, name string, record any) (any, error)
}

pact.SettingsItem already carries func fields (NewModel func() any), so a func field in a pact struct has precedent [VERIFIED: modules/pact/capabilities.go:162-175 — Form string \json:"-"`/NewModel func() any `json:"-"`]. **Do not add IDs []uint64` for toolbar selections in v1.** Unscoped ids would be an IDOR footgun, and adding them later is additive.

Pattern 2: YAML contract (discretion resolved)

fields.yaml widget (Winter-shaped type: widget + widget:):

discogs:
    label: golem15.fonoteka::lang.discogs.lookup_label
    type: widget
    widget: golem15-fonoteka-discogs-lookup   # custom-element tag
    action: discogsLookup                     # a registered AdminAction name
    fill: [year, format]                      # writable scalar fields only
    span: right

fields.yaml partial (Winter path:, identifier only):

summary:
    type: partial
    path: summary          # → {ConfigDir}/_summary.htm

config_list.yaml:

headerPartial: stats       # → {ConfigDir}/_stats.htm
toolbar:
    buttons: [create, delete, discogsSync]

Boot rules (all bootErr, all fail-closed):

  1. widget, action, fill are allowed only on type: widget, and path only on type: partial. A key on the wrong type is an error.
  2. The widget tag matches ^[a-z][a-z0-9]*(-[a-z0-9]+)+$, starts with {vendor}-{plugin}- of the owning plugin, and is not a reserved name. Per MDN, reserved names are "annotation-xml", "color-profile", "font-face", "font-face-src", "font-face-uri", "font-face-format", "font-face-name", "missing-glyph" [CITED: developer.mozilla.org/en-US/docs/Web/API/CustomElementRegistry/define]. The prefix rule also makes cross-plugin collisions (NotSupportedError on define) impossible.
  3. action names an entry in the controller's AdminActions().
  4. Every fill key is a field of the same form with a scalar type and present in cc.Writable (so not protected, and a model column). Validate after BindWritableFields.
  5. A form with any widget requires the controller to implement AdminClientAssets with at least one JS file.
  6. path / headerPartial pass identifier() and resolve to {ConfigDir}/_{name}.htm. The file must exist and parse, and the controller must implement AdminPartialData. Winter's $/… and ~/… forms are a boot error with a hint (no free-form paths means no traversal surface).
  7. toolbar.buttons names other than create/delete must be registered AdminActions. Duplicates stay an error.
  8. Action Permissions are validated like relation permissions (reg.validatePermissions), and action Label keys go through validateMessageKeys.
  9. Settings forms (compileSetting, which reuses decodeFields) reject widget and partial. A settings form has no controller for actions or view models.

Pattern 3: Action routes (cabana-owned)

Plugins cannot mount routes under the prefix [VERIFIED: modules/surf/admin_prefix_test.go — TestPhase10AdminPrefixCollision "fails boot when a plugin other than cabana registers a route at or under the admin prefix"]. Mount inside the existing r.GroupRaw(api, []string{"backend"}, …):

g.Post("/{vendor}/{plugin}/{controller}/widgets/{field}", requireAjax(s.widgetAction))
constrainController(g); g.Where("field", "[A-Za-z_][A-Za-z0-9_]*")
g.Post("/{vendor}/{plugin}/{controller}/toolbar/{action}", requireAjax(s.toolbarAction))
constrainController(g); g.Where("action", "[A-Za-z_][A-Za-z0-9_]*")
g.Get("/{vendor}/{plugin}/{controller}/partials/{name}", s.partial)   // ?id= optional
constrainController(g); g.Where("name", "[A-Za-z_][A-Za-z0-9_]*")

ServeMux has no conflict: the 5-segment POSTs have distinct literals (widgets, toolbar) and no other 5-segment POST exists; the 5-segment GET literal partials differs from schema. Handler order: protect (controller lookup, principal, controller perms) → the name must be declared in this surface (widget field / toolbar list / declared partial, else 404) → action perms (403) → decode the body with DisallowUnknownFields and trailing-token check (copy decodeRelationMutation) → if record_id is set, load through the FormExtendQuery scope without FOR UPDATE (loadRecord locks and is meant for write txs) → 404 if missing → Run → drop fill keys not in field.fill → translateKey(message) → WriteData(200, AdminActionResult). Errors go through writeCRUDError, so *cabana.ValidationError maps to 422.

Pattern 4: Partial host (D-17). Recommendation: server-sanitized node tree

Option Verdict Evidence
Server node tree + Vue h() Use No HTML string reaches a browser parser; passes the existing hygiene grep unchanged; theme/dark mode inherited; Go-testable; recursive type verified through swag → swagger2openapi → openapi-typescript this session
Sandboxed same-origin iframe Reject Every SPA response sets X-Frame-Options: DENY and frame-ancestors 'none' [VERIFIED: modules/boardwalk/boardwalk.go:32 const contentSecurityPolicy = "frame-ancestors 'none'; base-uri 'none'; object-src 'none'; script-src 'self'"; :164 h.Set("X-Frame-Options", "DENY")], so it needs a carve-out from T-10-04. sandbox="" gives an opaque origin, so the parent cannot auto-size it (fixed-height stats strip), the iframe doesn't get the .dark class or theme vars, and its subresource requests are cross-site (SameSite=Strict cookie not sent) [ASSUMED]
Sanitized slot (DOMPurify + v-html) Reject New npm dep breaks the T-10-SC exact-pin posture; v-html fails --hygiene [VERIFIED: scripts/check-phase10.sh:319 `grep -rnE 'v-html
Declarative shadow DOM Reject In an SPA, a client-side parser (setHTMLUnsafe/DOMParser/innerHTML) is needed to attach it, which reopens T-10-16. Shadow DOM encapsulates style but is not a script boundary [ASSUMED]

Server allowlist (mirror it in PartialHost.vue):

  • 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 attrs: class title lang dir role aria-* data-*. Per-tag: a[href] only if it starts with exactly one / (not // or /\) or # (same rule as safeRedirect, T-10-17); img[src] same-origin / path only, plus alt width height; td/th[colspan rowspan scope]; time[datetime]; data[value]; meter[value min max low high optimum]; progress[value max].
  • Drop id (DOM clobbering and collisions), style (keeps a future style-src possible; plugins use AdminCSS) and every on*.
  • Drop with subtree: script style template iframe object embed noscript textarea title xmp svg math form input button select link meta base. Any other unknown element is unwrapped (children kept).
  • Caps: 64 KiB template output, 2 000 nodes, depth 32. Exceeding a cap is a 500 with a server log, never a partial render.

The template gets a framework root {{ .Data }} plus a trans func bound per request. Parse the pristine template once at boot with a placeholder trans (Funcs must exist before Parse); per request Clone() the never-executed pristine copy and Funcs({"trans": bound}). Guard D-10: if reflect.TypeOf(vm) equals the controller model type (NewRecord() or its elem), refuse with 500.

Hygiene extension (new gate, not an edit to the Phase 10 regex): also refuse setHTML|setHTMLUnsafe|createContextualFragment|DOMParser|srcdoc|document\.write in admin/src.

Pattern 5: Asset serving (D-13..D-16)

  • URL layout: plugin file assets/js/lookup.js → {prefix}/assets/{vendor}/{plugin}/js/lookup.js?v={sha256[:12]}. Files must live under the plugin's assets/ directory; that prefix is stripped in the URL. assets is already a reserved vendor segment [VERIFIED: modules/cabana/registry.go:40 var reservedVendorSegments = map[string]bool{"api": true, "assets": true, "login": true, "settings": true}], so no controller can collide.
  • Boot: for each controller implementing AdminClientAssets, validate each path (clean, starts with assets/, no .., extension .js/.mjs/.css), read it from that plugin's AdminFS() (the file must be in the plugin's //go:embed list), hash it, and store map["vendor/plugin/js/lookup.js"] → {body, contentType, etag}. A missing file fails boot.
  • Route: g.Get("/assets/{vendor}/{plugin}/{file...}", s.pluginAsset) inside the existing GroupRaw(s.adminPrefix(), nil, …), which is public like the SPA shell. Winter plugin assets are public too; they are static code with no data. Lookup is an exact map key. On a miss, delegate to s.spa so Vite's flat dist/assets/* is never shadowed.
  • Headers: Content-Type: text/javascript; charset=utf-8 or text/css; charset=utf-8 (module scripts and nosniff'd stylesheets require them), X-Content-Type-Options: nosniff, the same CSP string, Cross-Origin-Resource-Policy: same-origin, Referrer-Policy: same-origin, Cache-Control: no-cache, ETag: "<sha256>". Serve through http.ServeContent (If-None-Match, HEAD). Do not reuse boardwalk's immutable rule: it keys on the assets/ prefix [VERIFIED: modules/boardwalk/boardwalk.go:142-146 if strings.HasPrefix(name, "assets/") { w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") }] and plugin files are not content-hashed. Export boardwalk's contentType/setSecurityHeaders (e.g. boardwalk.ContentType, boardwalk.SetSecurityHeaders) rather than duplicating them.
  • Schema exposure: add Assets ControllerAssets ({"scripts":[...],"styles":[...]}, always arrays) to both ListSchema and FormView, with full absolute URLs computed from the prefix.

Pattern 6: SPA loader, widget mounting, event contract

  • pluginAssets.ts: a module-level Map<string, Promise<void>>. Refuse any URL not starting with ${runtime.base}/assets/. Scripts are <script type="module" src> appended to document.head; the promise resolves on load and rejects on error, and a rejected entry is deleted so a later navigation can retry. Modules execute once per URL per document anyway. Styles use <link rel="stylesheet">: track them per controller and set disabled on links not owned by the active controller (D-14; prevents CSS bleed). No fetch( is used, so the hygiene "direct fetch" rule holds.
  • WidgetField.vue: onMounted → await load(schema.assets) (FormView calls it once; the widget awaits the shared promise) → Promise.race([customElements.whenDefined(tag), timeout(5000)]). Then document.createElement(tag) inside try/catch (an invalid name throws). Set attributes only: record-id ("" on create), field-name, locale (schema.meta.locale), fill-values (JSON of current values for field.fill). Append to a ref host div that Vue never renders children into, and add a listener for summer-action. Setting properties before upgrade shadows the class accessors [ASSUMED], so use attributes and update fill-values via watch.
  • Event: summer-action (bubbles, composed); detail is ignored in v1. While the POST runs, set busy on the element and ignore repeat events. On success, patch each k in field.fill that is present in result.fill through the injected patch function, then showToast(result.message). On error, show a danger toast and set state="error". On load failure or timeout, render an error box styled like UnsupportedField with a new backend::lang.form.widget_failed phrase.
  • Form plumbing: FieldControlProps carries only the field's own modelValue [VERIFIED: admin/src/components/form/control.ts:7-21], so add formContext.ts with typed InjectionKeys for a read-only values ref and patch(name, value). FormView provides them; no prop drilling through FormGrid/FormField.
  • registry.ts: register widget → WidgetField and partial → PartialField. Split "valueless" from "record-bound": isRegistered() must return false for widget/partial so editablePayload never sends them, while needsRecord() stays relation-manager only (widgets and partials render on create too).
  • ListView: headingButtons stays create. The toolbar gets delete plus names present in schema.toolbarActions (label per request, permission-filtered). A click runs api.POST('/{vendor}/{plugin}/{controller}/toolbar/{action}') → toast → reload the list and the header partial. PartialHost sits between <header> and the table card when schema.headerPartial is set, and re-fetches after bulk delete and after a toolbar action.

Pattern 7: OpenAPI wiring (D-15 pipeline)

Add annotation funcs in modules/cabana/admin_openapi.go for the three API routes (@Security BackendBearer, 200 plus 401/403/404, and 422 on the POSTs). Add response types Envelope[AdminActionResult] and Envelope[PartialView], and request type AdminActionRequest{RecordID *uint64 \json:"record_id,omitempty"`; Values map[string]any `json:"values,omitempty"`}. Regenerate with scripts/check-admin-openapi.sh(no args writesadmin/openapi/admin.json+admin/src/api/schema.d.ts; --check` diffs). Update both inventories:

  • phase09Routes in modules/cabana/security_coverage_test.go gets the 3 API routes and the asset route (public: true, spa: true). TestPhase09PermissionMatrix counts mounted routes, and TestPhase09ContractInventory requires len(spec.Paths) == len(pathsOf(phase09Routes)).
  • TestPhase10OpenAPIConformance gets one case per new route (it decodes with unknown fields disallowed).

admin/src/api/types.ts may alias only Schemas['cabana.X'] or GET path/query params [VERIFIED: scripts/check-phase10.sh:330 grep -vE "= (Schemas\['cabana\.[A-Za-z_-]+'\]|NonNullable<[A-Za-z]+Path\['get'\]\['parameters'\]\['query'\]>|[A-Za-z]+Path\['get'\]\['parameters'\]\['path'\])\$"]. Add PartialNode, PartialView, AdminActionResult, ControllerAssets and ToolbarAction as Schemas[...] aliases; leave POST path params inferred at call sites.

Anti-Patterns to Avoid

  • Letting plugins register the action POST route. Surf refuses it, and it would drop requireAjax/protect from framework control.
  • Validating custom toolbar names in toolbarButtons.UnmarshalYAML. Decode has no controller. Move the check into compileToolbarButtons(ctl, …).
  • Serving AdminFS wholesale under /assets/. That exposes YAML and templates. Serve the exact allowlist only.
  • Passing the GORM model to the template, or letting PartialData load the record itself by id (scope bypass). cabana loads the record through the scope; the controller only projects it.
  • Using loadRecord for reads. It takes FOR UPDATE.
  • Mounting the custom element with <component :is>. Vue picks property vs attribute by key in el, which flips at upgrade time. Create it imperatively with setAttribute.
  • Relying on Tailwind utility classes inside partial HTML or widget shadow DOM. They're purged from the build. Use plugin CSS plus the theme custom properties (var(--c-surface), var(--c-text), …; they inherit into shadow roots).

Don't Hand-Roll

Problem Don't Build Use Instead Why
HTML tokenizing/tree building of partial output regex/string splitting golang.org/x/net/html.ParseFragment HTML5 parsing edge cases (implicit closes, entities, void elements)
Contextual escaping of view-model values manual html.EscapeString html/template autoescape URL/CSS/attr contexts; javascript: → #ZgotmplZ [CITED: pkg.go.dev/html/template]
Conditional GET/HEAD for assets custom 304 logic http.ServeContent + ETag header Handles If-None-Match, HEAD, Range
CSRF on new POSTs new header check existing requireAjax Already walked by TestPhase10CSRF
Permission evaluation new matcher Allows() / reg.validatePermissions Wildcard .* semantics already correct
Record scope for widget/partial per-plugin query FormExtendQuery hook via a cabana helper Same scope as show/save (T-10-09 pattern)
Custom-element definition wait polling customElements.get customElements.whenDefined + timeout Standard promise API (probed in happy-dom)
MIME map new table export boardwalk's contentTypes/contentType One source for .js/.mjs/.css

Key insight: every dangerous primitive here (HTML parsing, escaping, CSRF, scoping, caching) already exists in the stdlib, x/net or the Phase 9/10 code. The phase is mostly wiring plus allowlists.

Common Pitfalls

Pitfall 1: Toolbar validation lives in YAML decode

What goes wrong: discogsSync fails boot even after registration. Why: [VERIFIED: modules/cabana/list_schema.go:288-289 // toolbarActions are the built-in toolbar actions; custom actions are Phase 10.1. / var toolbarActions = map[string]struct{}{"create": {}, "delete": {}}] and :306-307 return fmt.Errorf("toolbar.buttons: unsupported action %s (want create or delete)", action), inside UnmarshalYAML. How to avoid: Keep identifier and duplicate checks in decode. Resolve names against built-ins plus AdminActions() in compileToolbarButtons(ctl, …), and update the error text and its tests.

Pitfall 2: type: partial rejection is pinned by tests

What goes wrong: Lifting the rejection breaks the existing tests. Why: [VERIFIED: modules/cabana/form_schema.go:344-346 if typ == "partial" { return FormField{}, fmt.Errorf("type partial is not supported") }]; form_schema_test.go:224-232 and :274-280 assert that partial fails boot, including a path: $/golem15/acme/controllers/collections/_editors.htm case. How to avoid: Rewrite those cases. A partial without path, with a $/… path, or with a missing template must still fail, with the new messages.

Pitfall 3: Existing fonoteka tests pin the Albums shape

What goes wrong: fonoteka go test goes red after the YAML edits. Why: admin_albums_test.go:132 asserts len(schema.Fields) != 5, admin_albums_test.go:357 and admin_phase10_copy_test.go:48 assert toolbarButtons is "create,delete", and admin_phase10_controllers_test.go:100 compares served fields to the tracked fields.yaml keys. How to avoid: Update them in the same plan and commit as the YAML change (green at every commit).

Pitfall 4: valueless vs recordBound

What goes wrong: Widgets are hidden on create, or widget/partial names leak into the save body. Why: [VERIFIED: admin/src/components/form/registry.ts:45 const recordBound = new Set<string>([RELATION_MANAGER]) and :52-54 return renderers.has(type) && !recordBound.has(type)]. editablePayload sends every isRegistered type. How to avoid: Add a separate valueless set (relation-manager, widget, partial) for isRegistered; keep needsRecord relation-manager only. Server side, scalarFormField already skips non-scalar types in BindWritableFields [VERIFIED: modules/cabana/crud.go:797-800 case "text", "textarea", "number", "checkbox", "switch", "dropdown": return true].

Pitfall 5: Shared /assets/ namespace with Vite dist

What goes wrong: Plugin JS gets immutable caching, or a dist asset returns 404. How to avoid: Use a separate cabana handler with no-cache + ETag + ?v=. A miss falls through to the SPA handler. Add a test that GET {prefix}/assets/index-*.js still serves the dist file.

Pitfall 6: Vite dev proxy

What goes wrong: Plugin assets 404 under npm run dev. Why: [VERIFIED: admin/vite.config.ts proxy: { [\${devPrefix}/api`]: { target: devTarget, changeOrigin: false } }] proxies only /api. **How to avoid:** Add a ${devPrefix}/assetsproxy entry. This is a config-only change, but still runcheck-admin-dist.sh`.

Pitfall 7: Hygiene rules the new SPA files must satisfy

The Phase 10 hygiene stage fails on: application names in admin/src admin/tests admin/openapi modules/boardwalk modules/cabana modules/phrasebook (regex pl[yý]tarium|fonoteka|albumy|kolekcj|winyl|p[lł]yt[aęy], check-phase10.sh:315); any raw-HTML sink word, including in comments (:319); fetch( outside client.ts (:322); extra files in admin/src/api (:326); and any admin/src module not imported by a test (:350-357). The acme fixture, framework comments and test fixtures must avoid Płytarium/Discogs vocabulary; use "acme", "gadgets", "lookup".

Pitfall 8: Committed dist drift

What goes wrong: check-admin-dist.sh fails in CI. Why: Any change under admin/src (or new Tailwind classes) changes modules/boardwalk/dist. The script rebuilds from the lockfile and runs diff -r [VERIFIED: scripts/check-admin-dist.sh]. How to avoid: The SPA plan ends with npm --prefix admin run build and commits modules/boardwalk/dist in the same logical change. fonoteka embeds the framework through the go.work replace, so fonoteka UAT sees the SPA only after that commit. Plugin JS/CSS never triggers a Node rebuild; only SPA host changes do.

Pitfall 9: OpenAPI inventory counts

What goes wrong: Adding a route without updating phase09Routes fails TestPhase09PermissionMatrix (mounted count) and TestPhase09ContractInventory (path count). How to avoid: Update the inventory, annotations and conformance cases in the same commit, then run scripts/check-admin-openapi.sh and commit the regenerated files.

Pitfall 10: html/template Clone after Execute

What goes wrong: Clone() "returns an error if t has already been executed" (go doc, verified). How to avoid: Never execute the pristine boot template. Execute only clones.

Pitfall 11: Stats scoped to the wrong collection

What goes wrong: An admin sees counts from another collection (information disclosure). How to avoid: The stats query uses albumsAdminController.scopeAlbums(ctx, db). Add a Postgres test with two collections that asserts isolation.

Pitfall 12: Winter permission catalog

What goes wrong: A new golem15.fonoteka.discogs_* permission diverges from the PHP roles data. How to avoid: Use golem15.fonoteka.access_albums for both actions.

Pitfall 13: Name clash inside cabana

cabana already has partialSelection (bulk delete, crud.go:497). Name the new types PartialView/PartialNode/compiledPartial and keep them in their own file.

Pitfall 14: Fill keys that are not in the form

What goes wrong: A patched value is silently not saved (editablePayload only sends form fields), or a protected key is targeted. How to avoid: Boot rule 4 (fill ⊆ cc.Writable). Discogs fill: [year, format] needs a year field added to models/album/fields.yaml (the Year *int column exists) — see Open Question 1.

Code Examples

Partial render + allowlist (sketch)

// Source: pattern composed from go doc html/template (Clone/Funcs) and x/net/html ParseFragment
func (p *compiledPartial) render(ctx context.Context, tr *phrasebook.Translator, data any) ([]PartialNode, error) {
	t, err := p.pristine.Clone() // pristine is never executed
	if err != nil {
		return nil, err
	}
	t.Funcs(template.FuncMap{"trans": func(key string) string { return translateKey(ctx, tr, key) }})
	var buf bytes.Buffer
	if err := t.Execute(&limitedWriter{w: &buf, n: 64 << 10}, map[string]any{"Data": data}); err != nil {
		return nil, err
	}
	ctxNode := &html.Node{Type: html.ElementNode, Data: "div", DataAtom: atom.Div}
	nodes, err := html.ParseFragment(&buf, ctxNode)
	if err != nil {
		return nil, err
	}
	return sanitizeNodes(nodes, &budget{nodes: 2000, depth: 32}) // allowlist walk
}

Plugin custom element (fonoteka, plain JS, no fetch)

// assets/js/discogs-lookup.js — defined at module top level
class DiscogsLookup extends HTMLElement {
  static observedAttributes = ['busy', 'state']
  connectedCallback() {
    const root = this.shadowRoot ?? this.attachShadow({ mode: 'open' })
    if (!root.firstChild) {
      const button = document.createElement('button')
      button.type = 'button'
      button.textContent = this.getAttribute('label') ?? 'Discogs'
      button.addEventListener('click', () =>
        this.dispatchEvent(new CustomEvent('summer-action', { bubbles: true, composed: true })))
      root.append(button)
    }
  }
  attributeChangedCallback() {
    const b = this.shadowRoot?.querySelector('button')
    if (b) b.disabled = this.hasAttribute('busy')
  }
}
if (!customElements.get('golem15-fonoteka-discogs-lookup')) {
  customElements.define('golem15-fonoteka-discogs-lookup', DiscogsLookup)
}

(A label attribute is not in D-08's set. Either the SPA also sets the field's localized label or the element uses fixed text. Planner's choice; keep it attribute-only.)

SPA loader (sketch)

// admin/src/app/pluginAssets.ts
const scripts = new Map<string, Promise<void>>()
export function loadScript(url: string): Promise<void> {
  if (!url.startsWith(`${runtime.base}/assets/`)) return Promise.reject(new Error('foreign asset'))
  let p = scripts.get(url)
  if (!p) {
    p = new Promise<void>((resolve, reject) => {
      const el = document.createElement('script')
      el.type = 'module'
      el.src = url
      el.addEventListener('load', () => resolve(), { once: true })
      el.addEventListener('error', () => { scripts.delete(url); reject(new Error(url)) }, { once: true })
      document.head.appendChild(el)
    })
    scripts.set(url, p)
  }
  return p
}

Stub action (fonoteka)

func (c albumsAdminController) AdminActions() []pact.AdminAction {
	perms := []string{"golem15.fonoteka.access_albums"}
	return []pact.AdminAction{
		{Name: "discogsLookup", Permissions: perms, Run: func(ctx context.Context, in pact.AdminActionInput) (pact.AdminActionResult, error) {
			// Phase 14 replaces this stub with the Discogs client.
			return pact.AdminActionResult{
				Message: "golem15.fonoteka::lang.discogs.stub_filled",
				Fill:    map[string]any{"year": 1977, "format": "LP"},
			}, nil
		}},
		{Name: "discogsSync", Label: "golem15.fonoteka::lang.discogs.sync_button", Permissions: perms, Run: func(ctx context.Context, _ pact.AdminActionInput) (pact.AdminActionResult, error) {
			return pact.AdminActionResult{Message: "golem15.fonoteka::lang.discogs.stub_not_implemented"}, nil
		}},
	}
}

The phrase keys above are proposals [ASSUMED]. The discogs: group already exists in lang/pl/lang.yaml (line 233); add the keys to both en and pl.

State of the Art

Old Approach Current Approach When Changed Impact
Winter addJs/addCss + server-rendered backend pages Go controller method + SPA-loaded ES module custom elements This phase App devs still never need Node
Winter type: partial rendered inline as HTML html/template → sanitized node tree → Vue h() This phase No raw-HTML sink in the SPA
Winter AJAX onXxx handlers on the controller pact.AdminAction.Run dispatched from cabana routes This phase CSRF, auth and scope come from the framework
innerHTML + sanitizer libraries Structured rendering (and, emerging, the browser Sanitizer API setHTML) 2025-2026 setHTML browser support not verified here; not used [ASSUMED]

Deprecated/outdated: Phase 10 RESEARCH's "dev-mode switch serving plugin assets from disk". CONTEXT D-15 rejected it.

Assumptions Log

# Claim Section Risk if Wrong
A1 Sandboxed-iframe subresource requests don't carry the SameSite=Strict cookie, and an opaque-origin frame can't be auto-sized Pattern 4 Low. Only affects the rejected option
A2 Declarative shadow DOM needs a client-side HTML parser in an SPA Pattern 4 Low. Rejected option
A3 Setting properties on a not-yet-upgraded custom element shadows class accessors, so attributes are safer Pattern 6 Low. Attribute-only is safe regardless
A4 Dynamically inserted same-origin <script type=module src> is allowed under script-src 'self' Pattern 5/6 Medium. Verify in a browser during UAT (happy-dom does not enforce CSP)
A5 Proposed phrase keys, fixture fill {year: 1977, format: "LP"}, tag name, action names Code Examples Low. Discretion items
A6 Adding a year field to the Albums admin form is acceptable Pitfall 14 Medium. User may prefer fill: [format] only
A7 Browser setHTML Sanitizer API support status State of the Art None. Not used
A8 Promoting golang.org/x/net from indirect to direct is acceptable under the stdlib-first rule Standard Stack Medium. Fallback is encoding/xml non-strict

Open Questions (RESOLVED)

  1. Which fields does the Discogs widget fill? — RESOLVED (user, 2026-09-28): fill: [year, format], and a year field (type: number) is added to the Albums form. Recorded as CONTEXT D-19; closes A6.
    • Known: the Albums form has name, artists, format, genre, shelf. fill must be writable scalars, and the model has Year *int, Label, Country and others.
  2. Promote golang.org/x/net/html? — RESOLVED (user, 2026-09-28): approved. Recorded as CONTEXT D-18, the phase-decision note for CLAUDE.md rule 4; closes A8.
  3. Stats strip numbers. — RESOLVED (Claude's discretion, per CONTEXT): total albums in the active collection plus counts per format (one GROUP BY format query under scopeAlbums), plus a "without shelf" count, which the UI-SPEC includes.
  4. Widget label attribute. — RESOLVED (Claude's discretion): yes, the SPA passes the field's localized label as an additive attribute. It is not a token or cookie, so D-08's intent holds.
  5. Plan count. — RESOLVED (user, at the plan-count checkpoint): 4 plans (framework Go → framework SPA → fonoteka Albums → tests + gate).

Environment Availability

Dependency Required By Available Version Fallback
Go toolchain all Go work ✓ go1.27.0 —
golang.org/x/net (module cache) partial sanitizer ✓ v0.58.0 encoding/xml
Node admin build/tests ✓ v22.23.2 (engines >=22.6) —
npm npm ci ✓ 12.0.2 —
admin/node_modules vitest, openapi-typescript ✓ happy-dom 20.11.6, openapi-typescript 7.13.0 npm --prefix admin ci
swag via go run OpenAPI regen ✓ (probed) v1.16.6 —
Docker testcontainers Postgres ✓ 29.7.2 —
psql / pg_isready manual DB checks ✓ — —
Real browser CSP and custom-element UAT manual — human UAT step

Missing dependencies with no fallback: none. Missing with fallback: none. A real-browser check of CSP/module loading is a manual UAT item (A4).

Validation Architecture

Test Framework

Property Value
Framework Go testing (+ testcontainers Postgres); Vitest 3.2.7 + happy-dom 20.11.6 + @vue/test-utils 2.4.11
Config file admin/vitest.config.ts (include: ['tests/**/*.test.ts'], setup tests/setup.ts)
Quick run command go test ./modules/cabana -run 'TestPhase101' -count=1 and npm --prefix admin test -- tests/form tests/list tests/app
Full suite command go vet ./... && go test ./... (both repos, fonoteka with ./plugins/golem15/fonoteka/... ./plugins/golem15/user/...) and npm --prefix admin run typecheck && npm --prefix admin test
Phase gate scripts/check-phase10.1.sh --all (new; reuses the phase10_detect fail-closed detector pattern) plus scripts/check-phase10.sh --all staying green

Phase Requirements → Test Map

Decision Behavior Test Type Automated Command File Exists?
D-06, D-09 widget/partial types; key-per-type; tag regex + plugin prefix; unknown key fails unit go test ./modules/cabana -run '^TestPhase101FormExtensionSchema$' ❌ Wave 0
D-07 fill ⊆ writable scalar fields (boot); response fill filtered server-side unit + Postgres go test ./modules/cabana -run '^TestPhase101Actions$' ❌
D-11 headerPartial compile; missing _x.htm / parse error / missing AdminPartialData fails boot unit go test ./modules/cabana -run '^TestPhase101PartialSchema$' ❌
D-10, D-17 sanitizer allowlist: script/on*/style/javascript:, // hrefs dropped; escaping of record data; caps; model-type guard unit go test ./modules/cabana -run '^TestPhase101PartialSanitizer$' ❌
D-12 toolbar names resolved against AdminActions; unknown fails; label keys validated; permission-filtered in schema unit go test ./modules/cabana -run '^TestPhase101Toolbar$' ❌ (update list_schema_test.go)
D-13, D-16 asset allowlist; MIME; nosniff; CSP; CORP; ETag/304; traversal and undeclared file 404; dist fall-through unit go test ./modules/cabana -run '^TestPhase101Assets$' ❌
D-05 (server) new POSTs refused without X-Requested-With unit (auto) go test ./modules/cabana -run '^TestPhase10CSRF$' ✅ (auto-walks new routes)
D-12, D-05 inventory + OpenAPI + conformance of 3 new routes unit + Postgres `go test ./modules/cabana -run '^(TestPhase09PermissionMatrix TestPhase09ContractInventory
D-14 loader idempotent; foreign URL refused; retry after error; CSS disabled off-controller unit (vitest) npm --prefix admin test -- tests/app/pluginAssets.test.ts ❌
D-05, D-07, D-08 WidgetField: attributes set, event → POST body, patch only fill keys, busy, timeout → error box, create mode (empty record-id) unit (vitest) npm --prefix admin test -- tests/form/WidgetField.test.ts ❌
D-17 PartialHost renders tree via h(); unknown tag/attr dropped client-side; text stays text unit (vitest) npm --prefix admin test -- tests/list/PartialHost.test.ts ❌
D-03, D-12 ListView header slot; custom toolbar button → POST → toast → reload unit (vitest) npm --prefix admin test -- tests/list/ListToolbar.test.ts tests/views/ListView.test.ts ✅ (extend)
D-09 registry: widget/partial registered, not in payload, render on create unit (vitest) npm --prefix admin test -- tests/form/registry.test.ts tests/form/formState.test.ts ✅ (extend)
D-17 no raw-HTML sinks incl. setHTML/DOMParser/srcdoc; plugin assets contain no fetch(/XMLHttpRequest/document.cookie gate scripts/check-phase10.1.sh --hygiene (+ --self-test plants) ❌
D-04 (dist) committed dist matches the source gate scripts/check-admin-dist.sh ✅
D-01, D-02, D-03, D-12 Albums: stats strip scoped per collection; widget stub fills; discogsSync toasts; limited admin 403; assets served integration (Postgres) cd ../fonoteka.go && go test ./plugins/golem15/fonoteka -run '^TestPhase101AlbumsExtension$' -count=1 ❌
D-01 framework repo has no Płytarium names gate scripts/check-phase10.sh --hygiene ✅
A4 real browser loads module script under CSP; widget renders; fill then save persists manual UAT summer serve + browser manual

Sampling Rate

  • Per task commit: the quick run command for the touched side (cabana -run TestPhase101 or the touched vitest files), plus go vet ./....
  • Per wave merge: the full suite in both repos, scripts/check-admin-openapi.sh --check, and scripts/check-admin-dist.sh after SPA changes.
  • Phase gate: scripts/check-phase10.1.sh --all and scripts/check-phase10.sh --all green before /gsd-verify-work.

Wave 0 Gaps

  • modules/cabana/testdata/extension/ acme fixture tree (controllers/gadgets config yaml, _stats.htm, _summary.htm, models fields/columns, assets/js/lookup.js, assets/css/gadgets.css)
  • modules/cabana/phase101_*_test.go (schema, sanitizer, assets, actions, toolbar)
  • admin/tests/fixtures/extension.*.json (list/form schema with assets, widget, partial, toolbarActions; partial nodes)
  • admin/tests/app/pluginAssets.test.ts, tests/form/WidgetField.test.ts, tests/form/PartialField.test.ts, tests/list/PartialHost.test.ts (hygiene: every new src module must be imported by a test)
  • fonoteka.go/plugins/golem15/fonoteka/admin_phase101_albums_test.go
  • scripts/check-phase10.1.sh with --self-test, --go, --security, --postgres, --spa, --openapi, --dist, --hygiene, --evidence, --all
  • No framework install needed.

Security Domain

Applicable ASVS Categories

ASVS Category Applies Standard Control
V2 Authentication no (unchanged) existing backend guard, cookie/Bearer
V3 Session Management yes (unchanged) HttpOnly/Secure/SameSite=Strict cookie; plugin JS cannot read it (T-10-01)
V4 Access Control yes protect() + AdminAction.Permissions via Allows; scoped record load via FormExtendQuery; stats via scopeAlbums
V5 Input Validation yes strict YAML (DisallowUnknownField), JSON body DisallowUnknownFields, identifier path params, fill-key allowlists
V6 Cryptography no sha256 used for cache validation only
V12 Files and Resources yes exact asset allowlist, extension allowlist, no FS exposure
V13 API yes requireAjax on every new POST; OpenAPI conformance
V14 Configuration yes CSP script-src 'self', nosniff, CORP same-origin, explicit MIME

Known Threat Patterns (proposed register rows for 10.1-SECURITY review)

ID Pattern STRIDE Standard Mitigation Failing-when-broken test
T-10.1-01 Asset route serves undeclared files (YAML, templates) or traverses Info Disclosure exact-key allowlist built at boot; miss → SPA fall-through TestPhase101Assets
T-10.1-02 MIME sniffing / wrong type for module script Tampering explicit Content-Type + nosniff + CORP TestPhase101Assets
T-10.1-03 Stale plugin JS after rebuild Tampering ?v=hash + no-cache + ETag TestPhase101Assets
T-10.1-04 CSRF on widget/toolbar POST Tampering requireAjax TestPhase10CSRF (auto-walk), TestPhase10Coverage
T-10.1-05 Action run without permission EoP protect + action perms; perms validated at boot TestPhase101Actions, fonoteka limited-admin 403
T-10.1-06 IDOR via record_id / partial ?id= EoP / Info Disclosure cabana loads through FormExtendQuery; 404 out of scope TestPhase101Actions, TestPhase101AlbumsExtension
T-10.1-07 Fill mass-assignment Tampering fill ⊆ writable (boot); server drops extra keys; SPA patches only field.fill; save still runs ProjectWritableFields + rules TestPhase101Actions, WidgetField.test.ts
T-10.1-08 XSS via partial (record data → markup) Tampering html/template escaping + x/net/html allowlist tree + SPA h() allowlist; no raw-HTML sink; extended hygiene TestPhase101PartialSanitizer, PartialHost.test.ts, check-phase10.1.sh --hygiene
T-10.1-09 View model over-exposure / cross-collection stats Info Disclosure curated VM; model-type guard; scopeAlbums TestPhase101PartialSanitizer, TestPhase101AlbumsExtension
T-10.1-10 Plugin JS abuses the same-origin session EoP plugin JS is trusted compiled code (TCB, like plugin Go); CSP 'self'; HttpOnly cookie; hygiene scan of plugin assets/**/*.js for fetch(, XMLHttpRequest, document.cookie (D-05 convention) check-phase10.1.sh --hygiene
T-10.1-11 Custom-element / CSS collisions across plugins Tampering tag prefix {vendor}-{plugin}- at boot; CSS disabled off-controller TestPhase101FormExtensionSchema, pluginAssets.test.ts
T-10.1-12 DoS via huge partial output DoS 64 KiB / 2 000 nodes / depth 32 caps TestPhase101PartialSanitizer
T-10.1-13 Schema-supplied foreign script URL Tampering SPA refuses URLs outside ${base}/assets/; CSP backup pluginAssets.test.ts
T-10.1-SC Supply chain Tampering no npm change; x/net promoted from indirect check-phase10.sh --spa (npm ci)

T-10-16's residual note changes: "plugin HTML reaches the DOM only as a sanitized node tree". T-10-04 is unchanged (no framing carve-out).

Sources

Primary (HIGH confidence)

  • Codebase, read this session: modules/cabana/{form_schema,list_schema,registry,http,csrf,prefix,schema,schema_types,contracts,crud,settings,messages,admin_openapi}.go; modules/boardwalk/boardwalk.go; modules/pact/capabilities.go; modules/surf/{router,admin_prefix_test}.go; modules/cabana/{security_coverage,phase09_contract,phase10_csrf,openapi_conformance}_test.go; scripts/{check-phase10,check-admin-openapi,check-admin-dist}.sh; admin/{package.json,vite.config.ts,vitest.config.ts}; admin/src/{api/types.ts,api/client.ts,app/runtime.ts,components/form/*,components/list/ListToolbar.vue,views/ListView.vue,views/FormView.vue,styles/main.css}; fonoteka admin.go, admin_permissions.go, controllers/*.go, controllers/albums/*.yaml, models/album.go, models/album/fields.yaml, and existing admin tests.
  • Probes run this session: swag v1.16.6 + internal/tools/swagger2openapi + openapi-typescript 7.13.0 on a recursive PartialNode (success); happy-dom 20.11.6 custom elements, whenDefined, CustomEvent, script error event.
  • go doc html/template Template.Clone / Template.Funcs; go doc golang.org/x/net/html ParseFragment.

Secondary (MEDIUM confidence)

Tertiary (LOW confidence)

  • Browser behaviour of sandboxed iframes and declarative shadow DOM (training knowledge; used only to reject options).

Metadata

Confidence breakdown:

  • Standard stack: HIGH. Everything is in-tree or probed; one indirect module is promoted.
  • Architecture: HIGH. Constraints come from code read this session (surf prefix refusal, decode-time toolbar check, hygiene regexes, inventory tests).
  • Pitfalls: HIGH. Each is tied to a file and line or an existing test.
  • Browser runtime (CSP + module loading): MEDIUM. Needs manual UAT.

Research date: 2026-09-28 Valid until: 2026-10-28 (stable in-repo stack; re-check if Phase 11 changes cabana routing first)