Files
summercms/.planning/phases/10.1-runtime-admin-extension-point/10.1-CONTEXT.md
Jakub Zych 9b98d8409f docs(10.1): record D-18/D-19, resolve research questions, add pattern map
Plan checker iteration 1 flagged unresolved research questions and a
missing decision note for golang.org/x/net/html. D-18 approves x/net/html
for the partial sanitizer; D-19 fixes the Discogs widget fill to
[year, format]. STATE marks the phase ready to execute.
2026-09-28 22:44:34 +02:00

13 KiB
Raw Blame History

Phase 10.1: Runtime admin extension point - Context

Gathered: 2026-09-28 Status: Ready for planning

## Phase Boundary

Phase 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:

  1. A statistics strip above the Albums list (list chrome, declared in config_list.yaml, not a fake form field).
  2. A type: widget on 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-declared fill fields so write-back is proven now.
  3. 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 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.

Resolved at planning (2026-09-28)

  • D-18: golang.org/x/net/html becomes a direct dependency of the framework, used only by the cabana partial sanitizer (html.ParseFragment + tag/attribute allowlist). It is already in go.sum as an indirect dependency, so no new module enters the build. This is the decision note CLAUDE.md rule 4 asks for; encoding/xml was rejected as too weak on real HTML. — Reversibility: reversible
  • D-19: The Albums Discogs widget uses fill: [year, format]. A year field (type: number) is added to the Albums admin form; the model already has Year *int. The format-only option was rejected. — Reversibility: reversible

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.

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 (FieldRenderer seam), D-14 (toolbar.buttons create/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), CSP script-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-15 type: partial is a boot error (Collections editors became relation-manager).
  • cabana/form_schema.go — formFieldTypes, explicit reject of type: partial, unknown types fail boot.
  • cabana/list_schema.go — toolbar.buttons compiler (create/delete only; Winter string is a boot error).
  • pact/capabilities.go — AdminAssets is the YAML fs.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 — splits create vs 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 — stdlib html/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/ — Winter addJs/addCss and any remaining partials (editors is already relation-manager in 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: register widget and partial renderers; unknown types still fall back to UnsupportedField.
  • cabana form/list compilers: extend allowed types and toolbar action names; keep DisallowUnknownField and boot-fail behavior.
  • boardwalk / admin prefix serving: same-origin static files under {backend.uri} already exist for the SPA dist/.
  • 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 in fonoteka.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 / add type: widget in cabana/form_schema.go; extend toolbarButtons in cabana/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.go Albums 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.yaml that 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: partial in fields.yaml.
  • Winter addJs/addCss is the mental model for the Go method; plugins remain compiled and embedded.
## Deferred Ideas
  • 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