13 KiB
Phase 10.1: Runtime admin extension point - Context
Gathered: 2026-09-28 Status: Ready for planning
## Phase BoundaryPhase 10.1 opens the compiled admin SPA so a plugin can extend lists and forms without a Node rebuild: per-controller JS/CSS (Winter addJs/addCss), type: widget as custom elements (no Vue in plugins), type: partial via html/template (form fields and a list-header slot), and extra named toolbar actions.
Acceptance is three real Albums surfaces in fonoteka.go, plus a nameless fixture plugin in summercms.go for contract tests:
- A statistics strip above the Albums list (list chrome, declared in
config_list.yaml, not a fake form field). - A
type: widgeton the Albums form that renders a “load from Discogs” button. The click POSTs to a stub; Phase 14 replaces the stub with the real Discogs client. The stub may return a fixture payload that patches YAML-declaredfillfields so write-back is proven now. - A third Albums toolbar action (named, registered on the controller) that POSTs to its own stub and toasts.
Not in scope: the real Discogs HTTP client and jobs (Phase 14); WASM (FW-06 / v2); a dev-mode disk override for plugin assets; Ctrl+K, badge columns, Playwright, user/media navigation (Phase 10 deferred, unchanged).
## Implementation DecisionsAcceptance
- 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.
Reviewed Todos
- Backend admin personal API tokens (deferred Apparatus PersonalApiToken) — keyword-only match on “admin”; not this phase.
<canonical_refs>
Canonical References
Downstream agents MUST read these before planning or implementing.
Phase 10 contract this phase extends
.planning/phases/10-admin-vue-spa/10-CONTEXT.md— D-04 (embed, no Node for app plugins), D-05 (FieldRendererseam), D-14 (toolbar.buttonscreate/delete only; custom actions were deferred here), D-19 (httpOnly cookie because plugin JS will run same-origin). Deferred section named this phase..planning/phases/10-admin-vue-spa/10-RESEARCH.md— original 10.1 note:AdminAssets()/addJs/addCss, custom elements,type: partial, custom toolbar, disk switch (disk switch was rejected in this discussion)..planning/phases/10-admin-vue-spa/10-SECURITY-REVIEW.md— T-10-01 (cookie, no token in JS), T-10-16 (no raw HTML), T-10-SC (lockfile), CSPscript-src 'self'..planning/phases/10-admin-vue-spa/design/README.md—UnsupportedField, list toolbar, form field chrome the new types must fit.
Phase 9 schema pipeline (must change)
.planning/phases/09-backend-admin-authentication-and-schema-pipeline/09-CONTEXT.md— D-06 allowed types; D-15type: partialis a boot error (Collections editors becamerelation-manager).cabana/form_schema.go—formFieldTypes, explicit reject oftype: partial, unknown types fail boot.cabana/list_schema.go—toolbar.buttonscompiler (create/delete only; Winter string is a boot error).pact/capabilities.go—AdminAssetsis the YAMLfs.FS, not JS/CSS.
SPA extension seam
admin/src/components/form/registry.ts—rendererFor/UnsupportedField; comment already points at Phase 10.1.admin/src/components/form/FieldRenderer.vue— mounts the selected control.admin/src/components/list/ListToolbar.vue— renders declared button names.admin/src/views/ListView.vue— splitscreatevs toolbar buttons; insertion point for the list-header partial.
Project constraints
.planning/ROADMAP.md§Phase 10.1 — inserted after Phase 10; depends on Phase 10..planning/REQUIREMENTS.md— ADMIN-01..06 (ADMIN-06 complete). Planning should add an ADMIN-07 (or equivalent) for this extension point; do not silently reuse ADMIN-06..planning/PROJECT.md— compiled plugins only; two-repo split; WASM deferred (.planning/seeds/wasm-extension-api.md)..planning/research/STACK.md— stdlibhtml/template; no new Go dependency unless a phase decision names it.
Later phase that owns Discogs
.planning/ROADMAP.md§Phase 14 — Discogs client, CSV/Discogs jobs. Replaces the 10.1 stubs.
PHP reference (read-only)
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/— WinteraddJs/addCssand any remaining partials (editors is alreadyrelation-managerin Go).../fonoteka.go/plugins/golem15/fonoteka/controllers/albums/— Go Albums YAML this phase will edit.
</canonical_refs>
<code_context>
Existing Code Insights
Reusable Assets
FieldRenderer+registry.ts: registerwidgetandpartialrenderers; unknown types still fall back toUnsupportedField.cabanaform/list compilers: extend allowed types and toolbar action names; keepDisallowUnknownFieldand boot-fail behavior.boardwalk/ admin prefix serving: same-origin static files under{backend.uri}already exist for the SPAdist/.- Cookie + CSRF (
X-Requested-With) on unsafe admin routes: widget and toolbar POSTs must reuse this, not invent a second auth. phrasebook/messages:: toolbar label and toasts should be phrase keys, not hardcoded Polish in the SPA.- Plugin
embed.FS(pact.AdminAssets.AdminFS()): YAML today; JS/CSS/templates should live in the same plugin tree or a sibling embed.
Established Patterns
- Fail-loud boot on unknown YAML keys and unsupported types.
- Framework stays app-agnostic: fixture plugin in
summercms.go, Albums proof infonoteka.go. - Committed
dist/+ drift check: a new built-in field host (widget/partial chrome) needs a Node rebuild; plugin JS does not. - Optional capability interfaces type-asserted by the consumer (Phase 9/10 hooks).
Integration Points
- Lift
type: partial/ addtype: widgetincabana/form_schema.go; extendtoolbarButtonsincabana/list_schema.go. - New admin routes for widget action + toolbar action stubs, permission-gated like other Albums writes.
- SPA: widget control, partial host (D-17), list-header slot in
ListView, toolbar renderer for registered extra actions. - Asset routes under
{backend.uri}/assets/..., loaded when Albums navigation/controller is active. fonoteka.goAlbums controller: view model for stats, widget + toolbar stub handlers,addJs/addCss, YAML edits.- OpenAPI: new action paths must be typed in the framework admin document (Phase 10 D-15); no hand-maintained TS types.
</code_context>
## Specific Ideas- The user asked for a statistics partial above the Albums list as the real Płytarium proof, then a custom widget in
fields.yamlthat is a button to load data from Discogs. It is allowed to be a stub until full Discogs support (Phase 14); the point is that widgets render on forms. - “Above the list” was explicitly distinguished from
type: partialinfields.yaml. - Winter
addJs/addCssis the mental model for the Go method; plugins remain compiled and embedded.
- 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 Todos (not folded)
- Backend admin personal API tokens (deferred Apparatus PersonalApiToken) — matched only on the word “admin”; belongs with admin auth follow-up, not the SPA extension point.
Phase: 10.1-runtime-admin-extension-point Context gathered: 2026-09-28