# 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 (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).
## 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 |
## 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=` 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`, `