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

163 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Phase 10.1: Runtime admin extension point - Context
**Gathered:** 2026-09-28
**Status:** Ready for planning
<domain>
## 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).
</domain>
<decisions>
## 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.
</decisions>
<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>
<specifics>
## 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.
</specifics>
<deferred>
## 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.
</deferred>
---
*Phase: 10.1-runtime-admin-extension-point*
*Context gathered: 2026-09-28*