# 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`, `