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.
67 KiB
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.gostill 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, nottype: partialinfields.yaml. Formtype: partialis 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;FieldRendererPOSTs to the YAML-declared action with the existing cookie andX-Requested-WithCSRF header. Widget JS must notfetchthe admin API and must not read the JWT cookie. — Reversibility: costly — every widget and the CSRF matrix assume this split. - D-06:
fields.yamluses Winter-shapedtype: widgetplus a small key set: custom-element tag/path, POSTaction, andfill: [keys]for write-back. Unknown keys fail boot (DisallowUnknownField). — Reversibility: costly — every portedfields.yamlthat uses a widget writes this shape. - D-07: On success the SPA patches only the
fillkeys 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 currentfillvalues. 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 fortype: partialis lifted for a supported partial contract.type: widgetis added to the allowed form types. - D-10: Partials render with
html/templateagainst a curated view model the controller supplies. The template must not receive a raw GORM model or the request.html/templateescaping 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/deletebehavior 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.AdminAssetsremains the YAMLembed.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 watchrebuilds 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 staysscript-src 'self': nounsafe-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 (
headerPartialvs another spelling, widgetpathvstag, action path convention). - JS/CSS capability interface name (must not reuse
pact.AdminAssets). - Stub JSON envelope, toast copy, and the fixture
fillpayload 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: partialproof is wired if fonoteka has no real form partial (fixture plugin field vs a harmless unused Albums field). - Custom-element tag naming and
customElements.definetiming 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
discogsSyncor 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 (CSPscript-src 'self');type: widgetfields mount plugin custom elements whose actions the SPA posts with the admin cookie and CSRF header, patching only the declaredfillfields;type: partialform fields and aconfig_list.yamlheaderPartialrender server-side withhtml/templatefrom 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/httpServeMux,html/template,encoding/json). Add a dependency only when the research doc or a phase decision names it. This document namesgolang.org/x/net/html(alreadygolang.org/x/net v0.58.0 // indirectingo.mod); the planner should record it as a phase decision. go vet ./...andgo test ./...green at every commit, in both repos.- Compiled plugins only; no runtime plugin loading (stdlib
plugin, yaegi). - Two repositories:
summercms.gois framework-only and must contain no Płytarium names (hygiene-enforced).fonoteka.goholds the Albums work. Planning docs stay insummercms.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"). Reusegolem15.fonoteka.access_albumsfor 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)
Recommended Project Structure
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):
widget,action,fillare allowed only ontype: widget, andpathonly ontype: partial. A key on the wrong type is an error.- The
widgettag 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 (NotSupportedErroron define) impossible. actionnames an entry in the controller'sAdminActions().- Every
fillkey is a field of the same form with a scalar type and present incc.Writable(so not protected, and a model column). Validate afterBindWritableFields. - A form with any widget requires the controller to implement
AdminClientAssetswith at least one JS file. path/headerPartialpassidentifier()and resolve to{ConfigDir}/_{name}.htm. The file must exist and parse, and the controller must implementAdminPartialData. Winter's$/…and~/…forms are a boot error with a hint (no free-form paths means no traversal surface).toolbar.buttonsnames other thancreate/deletemust be registeredAdminActions. Duplicates stay an error.- Action
Permissionsare validated like relation permissions (reg.validatePermissions), and actionLabelkeys go throughvalidateMessageKeys. - Settings forms (
compileSetting, which reusesdecodeFields) rejectwidgetandpartial. 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 assafeRedirect, T-10-17);img[src]same-origin/path only, plusalt 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 futurestyle-srcpossible; plugins useAdminCSS) and everyon*. - 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'sassets/directory; that prefix is stripped in the URL.assetsis already a reserved vendor segment [VERIFIED: modules/cabana/registry.go:40var 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 withassets/, no.., extension.js/.mjs/.css), read it from that plugin'sAdminFS()(the file must be in the plugin's//go:embedlist), hash it, and storemap["vendor/plugin/js/lookup.js"] → {body, contentType, etag}. A missing file fails boot. - Route:
g.Get("/assets/{vendor}/{plugin}/{file...}", s.pluginAsset)inside the existingGroupRaw(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 tos.spaso Vite's flatdist/assets/*is never shadowed. - Headers:
Content-Type: text/javascript; charset=utf-8ortext/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 throughhttp.ServeContent(If-None-Match, HEAD). Do not reuse boardwalk'simmutablerule: it keys on theassets/prefix [VERIFIED: modules/boardwalk/boardwalk.go:142-146if strings.HasPrefix(name, "assets/") { w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") }] and plugin files are not content-hashed. Export boardwalk'scontentType/setSecurityHeaders(e.g.boardwalk.ContentType,boardwalk.SetSecurityHeaders) rather than duplicating them. - Schema exposure: add
Assets ControllerAssets({"scripts":[...],"styles":[...]}, always arrays) to bothListSchemaandFormView, with full absolute URLs computed from the prefix.
Pattern 6: SPA loader, widget mounting, event contract
pluginAssets.ts: a module-levelMap<string, Promise<void>>. Refuse any URL not starting with${runtime.base}/assets/. Scripts are<script type="module" src>appended todocument.head; the promise resolves onloadand rejects onerror, 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 setdisabledon links not owned by the active controller (D-14; prevents CSS bleed). Nofetch(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)]). Thendocument.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 forfield.fill). Append to arefhost div that Vue never renders children into, and add a listener forsummer-action. Setting properties before upgrade shadows the class accessors [ASSUMED], so use attributes and updatefill-valuesviawatch.- Event:
summer-action(bubbles, composed);detailis ignored in v1. While the POST runs, setbusyon the element and ignore repeat events. On success, patch eachk in field.fillthat is present inresult.fillthrough the injected patch function, thenshowToast(result.message). On error, show a danger toast and setstate="error". On load failure or timeout, render an error box styled likeUnsupportedFieldwith a newbackend::lang.form.widget_failedphrase. - Form plumbing:
FieldControlPropscarries only the field's ownmodelValue[VERIFIED: admin/src/components/form/control.ts:7-21], so addformContext.tswith typedInjectionKeys for a read-onlyvaluesref andpatch(name, value).FormViewprovides them; no prop drilling through FormGrid/FormField. - registry.ts: register
widget→ WidgetField andpartial→ PartialField. Split "valueless" from "record-bound":isRegistered()must return false forwidget/partialsoeditablePayloadnever sends them, whileneedsRecord()stays relation-manager only (widgets and partials render on create too). - ListView:
headingButtonsstayscreate. The toolbar getsdeleteplus names present inschema.toolbarActions(label per request, permission-filtered). A click runsapi.POST('/{vendor}/{plugin}/{controller}/toolbar/{action}')→ toast → reload the list and the header partial.PartialHostsits between<header>and the table card whenschema.headerPartialis 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:
phase09Routesinmodules/cabana/security_coverage_test.gogets the 3 API routes and the asset route (public: true, spa: true).TestPhase09PermissionMatrixcounts mounted routes, andTestPhase09ContractInventoryrequireslen(spec.Paths) == len(pathsOf(phase09Routes)).TestPhase10OpenAPIConformancegets 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/protectfrom framework control. - Validating custom toolbar names in
toolbarButtons.UnmarshalYAML. Decode has no controller. Move the check intocompileToolbarButtons(ctl, …). - Serving
AdminFSwholesale under/assets/. That exposes YAML and templates. Serve the exact allowlist only. - Passing the GORM model to the template, or letting
PartialDataload the record itself by id (scope bypass). cabana loads the record through the scope; the controller only projects it. - Using
loadRecordfor reads. It takesFOR UPDATE. - Mounting the custom element with
<component :is>. Vue picks property vs attribute bykey in el, which flips at upgrade time. Create it imperatively withsetAttribute. - 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)
- Which fields does the Discogs widget fill? — RESOLVED (user, 2026-09-28):
fill: [year, format], and ayearfield (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.fillmust be writable scalars, and the model hasYear *int,Label,Countryand others.
- Known: the Albums form has
- 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. - Stats strip numbers. — RESOLVED (Claude's discretion, per CONTEXT): total albums in the active collection plus counts per
format(oneGROUP BY formatquery underscopeAlbums), plus a "without shelf" count, which the UI-SPEC includes. - Widget
labelattribute. — 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. - 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 TestPhase101or the touched vitest files), plusgo vet ./.... - Per wave merge: the full suite in both repos,
scripts/check-admin-openapi.sh --check, andscripts/check-admin-dist.shafter SPA changes. - Phase gate:
scripts/check-phase10.1.sh --allandscripts/check-phase10.sh --allgreen 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.goscripts/check-phase10.1.shwith--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}; fonotekaadmin.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 recursivePartialNode(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)
- MDN
CustomElementRegistry.define(valid names, reserved names, exceptions): https://developer.mozilla.org/en-US/docs/Web/API/CustomElementRegistry/define - pkg.go.dev html/template (URL sanitization, ZgotmplZ): https://pkg.go.dev/html/template
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)