diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md index 1ecea79..392d81d 100644 --- a/.planning/REQUIREMENTS.md +++ b/.planning/REQUIREMENTS.md @@ -113,6 +113,7 @@ Requirements for v1 (the Płytarium port). Each maps to roadmap phases. "User" b - [ ] **ADMIN-04**: Admin CRUD endpoints per controller expose extension hooks (listExtendQuery, formExtendQuery, formBeforeCreate, formBeforeUpdate, relationExtendManageQuery), and bulk delete runs each record's lifecycle hooks - [ ] **ADMIN-05**: A settings model binds to a settings screen through the same schema pipeline (search_use_typesense) - [x] **ADMIN-06**: A minimal Vue 3 + TypeScript SPA renders login, permission-gated navigation, lists, forms and the relation manager for Albums, Artists, Collections, Genres and Styles using generated types +- [ ] **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. ### Quality and cutover (QA) @@ -225,6 +226,7 @@ Which phases cover which requirements. Updated during roadmap creation. | ADMIN-04 | Phase 9 | Pending | | ADMIN-05 | Phase 9 | Pending | | ADMIN-06 | Phase 10 | Complete | +| ADMIN-07 | Phase 10.1 | Pending | | QA-01 | Phase 2 | Complete | | QA-02 | Phase 2 | Complete | | QA-03 | Phase 2 | Complete | @@ -232,10 +234,10 @@ Which phases cover which requirements. Updated during roadmap creation. | QA-05 | Phase 15 | Pending | **Coverage:** -- v1 requirements: 76 total -- Mapped to phases: 76 +- v1 requirements: 77 total +- Mapped to phases: 77 - Unmapped: 0 ✓ --- *Requirements defined: 2026-09-16* -*Last updated: 2026-09-16 after roadmap revision (15 phases, split former Phase 13 into Phase 11 jobs/realtime/search infrastructure and Phase 14 domain jobs/integrations, reordered before the API phases; 100% coverage)* +*Last updated: 2026-09-28 after adding ADMIN-07 (Phase 10.1 runtime admin extension point); previously 2026-09-16 after roadmap revision (15 phases, split former Phase 13 into Phase 11 jobs/realtime/search infrastructure and Phase 14 domain jobs/integrations, reordered before the API phases; 100% coverage)* diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 91e78e3..7d83569 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -462,13 +462,33 @@ Plans: ### Phase 10.1: Runtime admin extension point (INSERTED) -**Goal:** [Urgent work - to be planned] -**Requirements**: TBD +**Goal:** A plugin extends the compiled admin SPA without a Node rebuild. Controllers declare their own JS/CSS, served same-origin from the plugin's embedded files; `type: widget` fields mount plugin custom elements whose actions the SPA posts; `type: partial` form fields and a list `headerPartial` render server-side through `html/template` and reach the page without any raw-HTML sink; and controllers register named toolbar actions. The framework contract is proven on a nameless fixture plugin, and the application proof is three Albums surfaces: a statistics strip above the list, a Discogs lookup widget on the form, and a Discogs sync toolbar action, both Discogs actions as stubs that Phase 14 replaces. +**Requirements**: ADMIN-07 **Depends on:** Phase 10 -**Plans:** 0 plans +**Repos:** summercms.go, fonoteka.go +**Success Criteria** (what must be TRUE): + + 1. A controller's declared JS/CSS is served from its plugin's embedded files under `{backend.uri}/assets/{vendor}/{plugin}/…` through an exact allowlist, loads only when that controller's list or form opens, and runs under CSP `script-src 'self'`; undeclared files and traversal attempts never leave the plugin tree. + 2. A `type: widget` field mounts the plugin's custom element; its event makes the SPA POST the declared action with the admin cookie and CSRF header, and only the field's declared `fill` keys are patched onto the unsaved form. + 3. A `config_list.yaml` `headerPartial` and a `type: partial` form field render server-side with `html/template` from a controller-supplied view model and reach the DOM only as an allowlisted node tree. + 4. A controller registers named toolbar actions listed in `toolbar.buttons`; a click POSTs the action and toasts while `create` and `delete` keep their behaviour; unknown YAML keys, missing templates, unregistered actions and unknown permissions fail boot. + 5. The Albums list shows a statistics strip scoped to the admin's collection, the Albums form shows a "Load from Discogs" widget whose stub fills Release year and Format, and a "Sync with Discogs" toolbar action toasts from its stub. + +**Plans:** 4 plans Plans: -- [ ] TBD (run $gsd-plan-phase 10.1 to break down) + +**Wave 1** +- [ ] 10.1-01-PLAN.md — Framework Go: pact capability interfaces, cabana widget/partial/toolbar/asset boot rules, cabana-owned action, partial and asset routes, sanitizer, OpenAPI (summercms.go) + +**Wave 2** *(blocked on Wave 1 completion)* +- [ ] 10.1-02-PLAN.md — Framework SPA: plugin asset loader, WidgetField, PartialHost and PartialField, list-header slot, custom toolbar buttons, rebuilt dist (summercms.go/admin) + +**Wave 3** *(blocked on Wave 2 completion)* +- [ ] 10.1-03-PLAN.md — Application: Albums stats strip, Discogs lookup widget stub, discogsSync toolbar stub, plugin assets and copy (fonoteka.go) + +**Wave 4** *(blocked on Wave 3 completion)* +- [ ] 10.1-04-PLAN.md — Unit tests last: Go and Vitest coverage, Albums acceptance, check-phase10.1.sh gate, security review and validation evidence (both repos) ### Phase 11: Jobs, realtime and search infrastructure diff --git a/.planning/phases/10.1-runtime-admin-extension-point/10.1-01-PLAN.md b/.planning/phases/10.1-runtime-admin-extension-point/10.1-01-PLAN.md new file mode 100644 index 0000000..e4b783c --- /dev/null +++ b/.planning/phases/10.1-runtime-admin-extension-point/10.1-01-PLAN.md @@ -0,0 +1,331 @@ +--- +phase: 10.1-runtime-admin-extension-point +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: + - go.mod + - go.sum + - modules/pact/capabilities.go + - modules/pact/README.md + - modules/boardwalk/boardwalk.go + - modules/boardwalk/README.md + - modules/cabana/form_schema.go + - modules/cabana/form_schema_test.go + - modules/cabana/list_schema.go + - modules/cabana/settings.go + - modules/cabana/extension.go + - modules/cabana/actions.go + - modules/cabana/plugin_assets.go + - modules/cabana/partial_render.go + - modules/cabana/contracts.go + - modules/cabana/registry.go + - modules/cabana/messages.go + - modules/cabana/schema_types.go + - modules/cabana/http.go + - modules/cabana/admin_openapi.go + - modules/cabana/README.md + - modules/cabana/security_coverage_test.go + - modules/cabana/openapi_conformance_test.go + - admin/openapi/admin.json + - admin/src/api/schema.d.ts + - admin/tests/fixtures/widgets.list-schema.json + - admin/tests/fixtures/widgets.form-schema.json + - admin/tests/fixtures/settings.json + - ../fonoteka.go/plugins/golem15/fonoteka/admin_phase09_security_test.go +autonomous: true +requirements: [ADMIN-07] +estimate: + tokens: 150000 + raw_tokens: 150000 + tasks: 3 + confidence: low +must_haves: + truths: + - "Per D-06 and D-09, fields.yaml accepts `type: widget` with exactly the keys `widget` (a custom-element tag that starts with the owning plugin's `{vendor}-{plugin}-` prefix), `action` (a name the controller registers through pact.HasAdminActions) and `fill` (writable scalar fields of the same form); a widget key on another type, an invalid, reserved or foreign-prefixed tag, an unregistered action, a fill key that is not a writable scalar field, or a widget on a controller that declares no JS file fails boot with an error naming plugin, controller and file." + - "Per D-05 and D-07, POST {prefix}/api/v1/{vendor}/{plugin}/{controller}/widgets/{field} is a cabana-owned route behind requireAjax, the controller permissions and the action's own permissions; it decodes a strict {record_id, values} body, loads record_id only through the controller's FormExtendQuery scope (404 when out of scope), runs the registered action and answers {message, fill} where fill holds only the field's declared fill keys with scalar values." + - "Per D-13, D-15 and D-16, every file a controller declares through pact.AdminClientAssets is read from its plugin's AdminFS at boot and served at {prefix}/assets/{vendor}/{plugin}/{path} with an explicit JavaScript or CSS Content-Type, nosniff, the admin CSP, Cross-Origin-Resource-Policy same-origin, no-cache and a sha256 ETag; list and form schemas carry those URLs with a ?v= hash; any other path under {prefix}/assets falls through to the SPA handler, so the embedded dist assets still load and plugin YAML or templates are never served." + - "Per D-12, toolbar.buttons accepts create, delete and names the controller registers as AdminActions; an unknown name, a registered action named create or delete, or a toolbar action without a label fails boot; POST .../toolbar/{action} runs the action behind requireAjax and permission checks and answers {message, fill: {}}; the list schema's toolbarActions lists only the actions the requesting admin may run, with localized labels; create and delete compile exactly as in Phase 10." + - "Per D-09, D-10, D-11 and D-17, `headerPartial: ` in config_list.yaml and `type: partial` with `path: ` in fields.yaml resolve to {ConfigDir}/_.htm, which must exist and parse at boot on a controller implementing pact.AdminPartialData; GET .../partials/{name} renders the template with html/template against the controller's view model and returns an allowlisted node tree in which script, style, iframe, svg and form subtrees, event-handler, style and id attributes, and javascript: or protocol-relative URLs never appear." + - "UI consideration (overflow S1/S2 partial output): output over 64 KiB, 2000 nodes or depth 32, and a view model whose type is the controller's own model, answer 500 with a server log and never a truncated or partial tree." + - "UI consideration (partial S2/S3 on create): a form partial without ?id= passes a nil record to PartialData and a widget POST without record_id runs the action with a nil Record, so both surfaces work on the create form." + - "Assumption delta (promote): the controller's HasAdminActions registry is the single action namespace; toolbar.buttons names and widget action: keys both resolve through CompiledController.Actions, and create/delete are reserved built-in names rather than a parallel list." + - "summercms.go stays application-agnostic (acme fixtures only), and after every task go vet ./... and the touched package tests pass in both repositories, scripts/check-admin-openapi.sh --check is clean, and the conformance test covers every new admin API route." + artifacts: + - path: "modules/pact/capabilities.go" + provides: "AdminClientAssets, AdminAction, AdminActionInput, AdminActionResult, HasAdminActions, AdminPartialData" + contains: "type AdminPartialData interface" + - path: "modules/cabana/extension.go" + provides: "Boot validation of widgets, actions, assets and partials per controller" + - path: "modules/cabana/actions.go" + provides: "Widget and toolbar action handlers, strict body decoding, scoped non-locking record read" + - path: "modules/cabana/plugin_assets.go" + provides: "Exact-allowlist plugin asset handler with SPA fall-through" + - path: "modules/cabana/partial_render.go" + provides: "html/template partial rendering, x/net/html allowlist walk, size caps, partial GET handler" + - path: "admin/openapi/admin.json" + provides: "Typed widgets, toolbar and partials operations and the PartialNode, PartialView, AdminActionRequest, AdminActionResult, ControllerAssets, ToolbarAction schemas" + key_links: + - from: "modules/cabana/http.go" + to: "modules/cabana/actions.go" + via: "cabana-owned POST routes wrapped in requireAjax" + pattern: "requireAjax\\(s\\.(widgetAction|toolbarAction)\\)" + - from: "modules/cabana/extension.go" + to: "modules/pact/capabilities.go" + via: "type assertions for HasAdminActions, AdminClientAssets and AdminPartialData" + pattern: "pact\\.(HasAdminActions|AdminClientAssets|AdminPartialData)" + - from: "modules/cabana/plugin_assets.go" + to: "modules/boardwalk/boardwalk.go" + via: "shared security headers and MIME map" + pattern: "boardwalk\\.(SetSecurityHeaders|ContentType)" + - from: "modules/cabana/partial_render.go" + to: "golang.org/x/net/html" + via: "ParseFragment then allowlist walk" + pattern: "html\\.ParseFragment" + - from: "modules/cabana/openapi_conformance_test.go" + to: "admin/openapi/admin.json" + via: "every inventoried route decoded into its documented type" + pattern: "widgets/\\{field\\}" + prohibitions: + - "No plugin mounts a route under the admin prefix; widget and toolbar actions run only from cabana-owned routes." + - "The asset route never serves a plugin AdminFS wholesale; only exact keys built at boot are served." + - "No template receives a GORM model, a request or a raw HTML string marked safe; record data is escaped by html/template." + - "No npm package is added; golang.org/x/net is promoted from indirect to direct with no new module in go.sum." +--- + +## Phase Goal + +A plugin extends the compiled admin SPA without a Node rebuild: controller JS/CSS served same-origin from embedded files, `type: widget` custom elements whose actions the SPA posts, `type: partial` and list `headerPartial` rendered server-side without a raw-HTML sink, and registered toolbar actions (ADMIN-07). + + +Build the framework Go half of the extension point in summercms.go: the pact capability contracts, cabana's YAML and boot rules for widgets, partials, header partials and custom toolbar actions, the cabana-owned action, partial and asset routes, the partial sanitizer, the typed OpenAPI document, and a nameless acme fixture proving each path through the conformance test. + +Purpose: Plans 10.1-02 (SPA) and 10.1-03 (application) build against these contracts and routes. Decisions implemented: D-05, D-06, D-07 (server filter), D-09, D-10, D-11, D-12, D-13, D-15, D-16, D-17 (server half); D-01 and D-03 framework proof via the acme fixture; D-02 only in the sense that the action contract lets a stub return a fixture payload. +Output: pact interfaces, cabana extension/actions/assets/partials code, exported boardwalk helpers, regenerated admin OpenAPI and TypeScript types, updated inventories and READMEs. + +Repos: summercms.go for all code; the one fonoteka.go test edit per task (the assembled route list) is committed in the fonoteka.go repository as its own commit. Planning docs and code go in separate commits. Never add co-author tags. + + + +@~/.claude/gsd-core/workflows/execute-plan.md +@~/.claude/gsd-core/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/10.1-runtime-admin-extension-point/10.1-CONTEXT.md +@.planning/phases/10.1-runtime-admin-extension-point/10.1-RESEARCH.md +@.planning/phases/10.1-runtime-admin-extension-point/10.1-PATTERNS.md +@.planning/phases/10.1-runtime-admin-extension-point/10.1-UI-SPEC.md +@modules/pact/capabilities.go +@modules/cabana/form_schema.go +@modules/cabana/list_schema.go +@modules/cabana/registry.go +@modules/cabana/http.go +@modules/cabana/schema_types.go +@modules/cabana/contracts.go +@modules/boardwalk/boardwalk.go + + +Existing seams (read, do not re-derive): +- modules/cabana/http.go `func (s *service) protect(w http.ResponseWriter, r *http.Request, fn func(*CompiledController))` does controller lookup (404), principal (401) and controller permissions (403) in that order; `func decodeRelationMutation(r *http.Request)` is the strict-body idiom (UseNumber, DisallowUnknownFields, trailing-token check); `mount` has the backend `r.GroupRaw(api, []string{"backend"}, ...)` group and the public prefix group serving `g.Get("", s.serveSPA)` and `g.Get("/{path...}", s.serveSPA)`; `constrainController(g)` and `constrainRelation(g)` apply Where rules to the last route. +- modules/cabana/crud.go: `func writeCRUDError(w, err)` maps *ValidationError→422, recordNotFound→404, partialSelection→409, else 500; `func loadRecord(ctx, tx, cc, dest, pk)` applies pact.FormExtendQuery then takes a row lock (write paths only); `func newWritableModel(cc)`; `func BindWritableFields(cc)` fills cc.Writable; `func scalarFormField(typ)`; `func nestedValue(val)`; `func coercePK(model, id)`. +- modules/cabana/contracts.go: `WriteData(w, status, data, meta)`, `WriteError(w, status, code, message)`, `Allows(principal, required)`, `requiredOf(ctl)`, `CompiledController{PluginID, Controller, List, Form, Relations, Writable, FieldRelations}`. +- modules/cabana/registry.go: `compileRegistry(items)` compiles list, form, relations, field relations then `BindWritableFields`; `compileContributions(reg, plugins)` validates permissions after every plugin's permissions are known (`reg.validatePermissions(owner, codes)`); `reservedVendorSegments` already reserves `assets`. +- modules/cabana/list_schema.go: `toolbarActions` map and the membership check inside `toolbarButtons.UnmarshalYAML`; `compileToolbarButtons(toolbar, showCheckboxes)`; `withoutAction` in registry.go strips create when there is no form. +- modules/cabana/form_schema.go: `formFieldTypes`, `formFieldKeys`, `compileFieldNode` (rejects `type partial` at the typ check), `translateKey(ctx, tr, key)`, `bootErr(pluginID, controllerID, file, err)` (schema.go), `identifier(s)` (schema.go). +- modules/boardwalk/boardwalk.go: unexported `contentType(name)`, `setSecurityHeaders(h)`, const `contentSecurityPolicy`; `serveFile` gives hashed `assets/` dist files one-year caching (never used for plugin files). +- Tests pinning current behaviour that must stay green: modules/cabana/form_schema_test.go "partial", "partial path" and "bad form fails activation" (errors must contain "partial" or "path"); messages_test.go expects "unsupported action export"; list_schema_test.go expects "drop_database" in the unsupported-action error; security_coverage_test.go `phase09Routes` and `phase09ProtectedCalls`; openapi_conformance_test.go requires one case per inventoried API route; ../fonoteka.go/plugins/golem15/fonoteka/admin_phase09_security_test.go `phase09AdminRoutes` must equal the assembled API route set exactly. + + + +## Planning notes + +- Spec-less probe fallback skipped: no requirement IDs were mapped for Phase 10.1 before this planning run; ADMIN-07 is introduced by it (REQUIREMENTS.md). Truths are derived from CONTEXT D-01..D-17 and the UI-SPEC. +- D-12's "POST path" is realised as the cabana-owned route `.../toolbar/{action}`: surf refuses any plugin route under the admin prefix (`TestPhase10AdminPrefixCollision`), so a controller registers the action (name, label, permissions, Go handler) and cabana owns the path, CSRF, auth and scope. +- `golang.org/x/net/html` is the dependency named by 10.1-RESEARCH (Standard Stack, Open Question 2); it is already `golang.org/x/net v0.58.0 // indirect` in go.mod, so promoting it adds no module (CLAUDE.md rule 4). + + +Signal: `chosen` ("custom"): toolbar actions change from a closed constant set to controller-registered names. +Primary noun: the controller action name (`pact.AdminAction.Name`), resolved per controller through `CompiledController.Actions`. +Decision: promote. The registry is the single namespace for toolbar.buttons and widget `action:`; `create` and `delete` stay built-in behaviours (D-12 locks them unchanged) but become reserved names in that namespace, so a registered action named create or delete fails boot. Invariant test (added in 10.1-04 TestPhase101Toolbar): every toolbar.buttons name resolves to exactly one built-in or registered action. + + +## Artifacts this phase produces + +- pact: `AdminClientAssets` (`AdminJS() []string`, `AdminCSS() []string`), `AdminAction` (`Name`, `Label`, `Permissions`, `Run`), `AdminActionInput` (`Field`, `RecordID *uint64`, `Record any`, `Values map[string]any`), `AdminActionResult` (`Message`, `Fill`), `HasAdminActions` (`AdminActions() []AdminAction`), `AdminPartialData` (`PartialData(ctx, name, record) (any, error)`) +- boardwalk: exported `ContentType(name string) string`, `SetSecurityHeaders(h http.Header)` +- cabana types: `AdminActionRequest` (`record_id`, `values`), `AdminActionResult` (`message`, `fill`), `ControllerAssets` (`scripts`, `styles`), `ToolbarAction` (`name`, `label`), `PartialNode` (`tag`, `attrs`, `text`, `children`), `PartialView` (`nodes`); `CompiledController.Actions map[string]pact.AdminAction`; new JSON keys `FormField.widget|action|actionLabel|fill|path`, `ListSchema.headerPartial|toolbarActions|assets`, `FormView.assets` +- cabana functions: `compileExtension`, `widgetTagPrefix`, `(*service).widgetAction`, `(*service).toolbarAction`, `(*service).partial`, `(*service).pluginAsset`, `decodeActionRequest`, `readScopedRecord`, `(*compiledPartial).render`, constants `partialMaxBytes` (64 KiB), `partialMaxNodes` (2000), `partialMaxDepth` (32) +- cabana annotation functions: `AdminWidgetAction`, `AdminToolbarAction`, `AdminPartial` +- YAML keys: fields.yaml `type: widget` + `widget`, `action`, `fill`; `type: partial` + `path`; config_list.yaml `headerPartial`; toolbar.buttons custom names +- Routes: `POST {prefix}/api/v1/{vendor}/{plugin}/{controller}/widgets/{field}`, `POST .../toolbar/{action}`, `GET .../partials/{name}` (optional `?id=`), `GET {prefix}/assets/{vendor}/{plugin}/{file...}` +- Template file convention: `{ConfigDir}/_{name}.htm` with a `trans` function and root `.Data` +- Fixture: acme conform plugin gains widget `lookup` (tag `acme-conform-lookup`), toolbar action `recount`, partials `stats` and `summary`, assets `assets/js/lookup.js` and `assets/css/gadgets.css` + + + + + Task 1: An acme widget field posts its registered action through cabana and gets back only its fill keys + D-05 and D-06 fix the SPA-owns-HTTP split and the widget YAML key set, and the pact AdminAction shape is the contract every plugin implements; the user locked D-05/D-06, so this is flagged without a checkpoint. + Phases 10 and 10.2 are executed: `test -f modules/cabana/admin_openapi.go && test -f scripts/check-phase10.sh && test -f ../fonoteka.go/plugins/golem15/fonoteka/admin_phase09_security_test.go` succeeds. + modules/pact/capabilities.go, modules/pact/README.md, modules/cabana/form_schema.go, modules/cabana/settings.go, modules/cabana/extension.go, modules/cabana/actions.go, modules/cabana/contracts.go, modules/cabana/registry.go, modules/cabana/messages.go, modules/cabana/schema_types.go, modules/cabana/http.go, modules/cabana/admin_openapi.go, modules/cabana/README.md, modules/cabana/security_coverage_test.go, modules/cabana/openapi_conformance_test.go, admin/openapi/admin.json, admin/src/api/schema.d.ts, ../fonoteka.go/plugins/golem15/fonoteka/admin_phase09_security_test.go + modules/pact/capabilities.go, modules/pact/README.md, modules/cabana/form_schema.go, modules/cabana/settings.go, modules/cabana/registry.go, modules/cabana/contracts.go, modules/cabana/messages.go, modules/cabana/crud.go (loadRecord, writeCRUDError, newWritableModel, BindWritableFields, scalarFormField, nestedValue), modules/cabana/http.go (mount, protect, relationMutation, decodeRelationMutation), modules/cabana/schema_types.go, modules/cabana/admin_openapi.go (AdminBulkDelete block), modules/cabana/security_coverage_test.go, modules/cabana/openapi_conformance_test.go (conformPlugin, conformController, conformFS, case list), modules/cabana/phase10_csrf_test.go, scripts/check-admin-openapi.sh, ../fonoteka.go/plugins/golem15/fonoteka/admin_phase09_security_test.go, .planning/phases/10.1-runtime-admin-extension-point/10.1-RESEARCH.md (Patterns 1-3) + (1) Contracts first, all six at once so later tasks and plans build against them (per D-13, which forbids reusing pact.AdminAssets). In modules/pact/capabilities.go after AdminRecordSource add: `AdminClientAssets` with `AdminJS() []string` and `AdminCSS() []string` (paths relative to the plugin's AdminFS, under `assets/`); struct `AdminAction` with `Name string`, `Label string` (phrase key or literal; toolbar and widget button text), `Permissions []string` (checked in addition to the controller's RequiredPermissions) and `Run func(ctx context.Context, in AdminActionInput) (AdminActionResult, error)` tagged `json:"-"` (SettingsItem.NewModel precedent); struct `AdminActionInput` with `Field string` (empty for a toolbar action), `RecordID *uint64` (nil on create and for toolbar actions), `Record any` (loaded by cabana through FormExtendQuery; nil when RecordID is nil) and `Values map[string]any` (the widget's fill snapshot, already reduced to its fill keys); struct `AdminActionResult` with `Message string` and `Fill map[string]any`; interface `HasAdminActions` with `AdminActions() []AdminAction`; interface `AdminPartialData` with `PartialData(ctx context.Context, name string, record any) (any, error)` documented as returning a curated view model, never the GORM model (D-10). Doc comments say toolbar actions carry no record ids, so an id list cannot become an unscoped lookup. Add all six to modules/pact/README.md Features and API reference in the same commit. + +(2) YAML (D-06): in modules/cabana/form_schema.go add `widget` to formFieldTypes and `widget`, `action`, `fill` to formFieldKeys. After the type is known, any of those three keys on a type other than widget is an error naming the key and "type: widget". Type widget requires `widget` (string) and `action` (identifier); `fill` is an optional sequence of identifiers without duplicates (use sequenceValues). Leave the existing partial-type rejection in place (Task 3 lifts it). In schema_types.go FormField gains `Widget string json:"widget,omitempty"`, `Action string json:"action,omitempty"`, `ActionLabel string json:"actionLabel,omitempty"` and `Fill []string json:"fill,omitempty"`; FormSchema.Localize translates ActionLabel with translateKey. modules/cabana/settings.go compileSetting refuses a widget field with an error naming the setting code (a settings form has no controller to own actions). + +(3) Boot: new modules/cabana/extension.go with `compileExtension(pluginID string, cc *CompiledController, fsys fs.FS) error`, called from compileRegistry right after BindWritableFields (fill validation needs cc.Writable). It type-asserts pact.HasAdminActions into the new exported `CompiledController.Actions map[string]pact.AdminAction` (contracts.go): each Name is an identifier, unique within the controller, not `create` or `delete` (reserved built-ins, assumption-delta decision) and has a non-nil Run. For every widget field of cc.Form: the tag matches `^[a-z][a-z0-9]*(-[a-z0-9]+)+$`, starts with `widgetTagPrefix(pluginID)` (the plugin ID lowercased with `.` and `_` replaced by `-`, plus a trailing `-`) and is not one of annotation-xml, color-profile, font-face, font-face-src, font-face-uri, font-face-format, font-face-name, missing-glyph; the action is registered; every fill key names a field of the same form that is in cc.Writable (so a scalar, non-protected model column). Copy the action Label into field.ActionLabel. Wrap every error with bootErr(pluginID, controller ID, the fields.yaml path, err). In registry.go compileContributions validate each action's Permissions with `reg.validatePermissions("action "+id+"."+name, ...)` next to the relation permissions. In messages.go validateMessageKeys, an action Label containing `::` must pass tr.Has (literal text passes), with an error naming the action. + +(4) Route (D-05, D-07): new modules/cabana/actions.go. In http.go mount, inside the backend GroupRaw, add `g.Post("/{vendor}/{plugin}/{controller}/widgets/{field}", requireAjax(s.widgetAction))` followed by constrainController(g) and `g.Where("field", "[A-Za-z_][A-Za-z0-9_]*")`. `(*service).widgetAction` order: s.protect; the path field must be a `type: widget` field of cc.Form (else 404 not_found); look up cc.Actions[field.Action]; when `!Allows(principal, action.Permissions)` log via s.logAuth and answer 403 forbidden; `decodeActionRequest(r)` copies decodeRelationMutation's strict idiom into `AdminActionRequest` (invalid or trailing body is a *ValidationError on "body", so 422); when RecordID is set, `readScopedRecord(ctx, db, cc, id)` builds the model with newWritableModel, applies pact.FormExtendQuery, matches the primary column and Takes one row with no row lock (loadRecord's lock belongs to write transactions), mapping not-found to recordNotFound (404); reduce Values to the field's fill keys whose values are JSON scalars or null (drop nested values with nestedValue); call action.Run with pact.AdminActionInput{Field, RecordID, Record, Values}; a *ValidationError goes through writeCRUDError (422), any other error is logged and answered with the generic 500 body without echoing the error text; reduce result.Fill to the field's fill keys with scalar values; translate Message with translateKey; `WriteData(w, 200, AdminActionResult{Message, Fill}, nil)` with Fill never nil. + +(5) OpenAPI: in modules/cabana/admin_openapi.go add `AdminActionRequest` (`RecordID *uint64 json:"record_id,omitempty"`, `Values map[string]any json:"values,omitempty"`), `AdminActionResult` (`Message string json:"message"`, `Fill map[string]any json:"fill"`) and the annotation func `AdminWidgetAction` modelled on AdminBulkDelete (path params vendor, plugin, controller, field; `@Param body body AdminActionRequest true`; `@Success 200 {object} Envelope[AdminActionResult]`; 401, 403, 404, 422 failures; `@Router /{vendor}/{plugin}/{controller}/widgets/{field} [post]`). Run `scripts/check-admin-openapi.sh` without arguments and commit admin/openapi/admin.json and admin/src/api/schema.d.ts. + +(6) Keep every inventory green in the same commit: security_coverage_test.go phase09Routes gains `POST /{vendor}/{plugin}/{controller}/widgets/{field}` and phase09ProtectedCalls gains `{"widget-action", (*service).widgetAction}`. In openapi_conformance_test.go conformController implements HasAdminActions with action `lookup` (Label "Look up", Permissions acme.conform.access, Run returning Message "Looked up" and Fill with `name` set from the stamp and `active: true`; `active` is outside the field's fill, so the case proves the server filter); conformFS fields.yaml gains `lookup: {label: Lookup, type: widget, widget: acme-conform-lookup, action: lookup, fill: [name]}`; add the case `POST /{vendor}/{plugin}/{controller}/widgets/{field}` (status 200, ref `cabana.Envelope-cabana_AdminActionResult`) after the create case, posting `{"record_id": gadgetID, "values": {"name": "x", "active": false}}` to `/acme/conform/gadgets/widgets/lookup`, and assert in that case that data.fill has exactly the key `name`. In ../fonoteka.go/plugins/golem15/fonoteka/admin_phase09_security_test.go add `{"POST " + adminAPI("/{vendor}/{plugin}/{controller}/widgets/{field}"), false}` to phase09AdminRoutes and commit it in the fonoteka.go repository. + +(7) modules/cabana/README.md: a Features bullet for widgets and actions, the route row, and API reference rows for AdminActionRequest and AdminActionResult (check each named identifier with `go doc ./modules/cabana `). Framework text and fixtures use acme names only. + + go vet ./... && go test ./modules/pact ./modules/cabana -count=1 && go test ./modules/cabana -run '^(TestPhase09PermissionMatrix|TestPhase09ContractInventory|TestPhase10OpenAPIConformance|TestPhase10CSRF|TestPhase10Coverage)$' -count=1 -v && scripts/check-admin-openapi.sh --check && npm --prefix admin run typecheck && (cd ../fonoteka.go && go test ./plugins/golem15/fonoteka -run '^(TestPhase09SecurityRoutes|TestPhase10Controllers|TestAlbumsAdminForm)$' -count=1 -v) + Any command exits non-zero; the verbose runs lack a "--- PASS" line for TestPhase09PermissionMatrix, TestPhase09ContractInventory, TestPhase10OpenAPIConformance, TestPhase10CSRF, TestPhase10Coverage, TestPhase09SecurityRoutes, TestPhase10Controllers or TestAlbumsAdminForm, or print "no tests to run" or "--- SKIP"; check-admin-openapi.sh prints a diff or "stale". + + + - `go doc ./modules/pact AdminClientAssets`, `go doc ./modules/pact AdminAction`, `go doc ./modules/pact AdminActionInput`, `go doc ./modules/pact AdminActionResult`, `go doc ./modules/pact HasAdminActions` and `go doc ./modules/pact AdminPartialData` each exit 0. + - `grep -c 'requireAjax(s.widgetAction)' modules/cabana/http.go` prints 1. + - `python3 -c "import json;d=json.load(open('admin/openapi/admin.json'));p=d['paths']['/{vendor}/{plugin}/{controller}/widgets/{field}']['post'];assert p['responses']['200']['content']['application/json']['schema']['\$ref'].endswith('Envelope-cabana_AdminActionResult')"` exits 0. + - `grep -c 'widgets/{field}' modules/cabana/security_coverage_test.go` and `grep -c 'widgets/{field}' ../fonoteka.go/plugins/golem15/fonoteka/admin_phase09_security_test.go` each print at least 1. + - TestPhase10OpenAPIConformance's widget case asserts the response fill has only the `name` key although the fixture action returned `active` too. + - `grep -c 'AdminClientAssets' modules/pact/README.md` and `grep -c 'widgets/{field}' modules/cabana/README.md` each print at least 1. + + A registered widget action on the acme fixture runs end to end through YAML, boot validation, the cabana route, the plugin's Go handler and the typed envelope, and both repositories' route inventories and the OpenAPI conformance test agree. + + + + Task 2: Controllers serve their own JS/CSS same-origin and register named toolbar actions + D-16 puts plugin asset URLs under the admin prefix with the CSP unchanged, and D-12 grows the toolbar compiler into a registration table; the CSP/cookie threat model and every list YAML depend on both, and both are user-locked, so no checkpoint. + modules/boardwalk/boardwalk.go, modules/boardwalk/README.md, modules/cabana/plugin_assets.go, modules/cabana/extension.go, modules/cabana/list_schema.go, modules/cabana/actions.go, modules/cabana/schema_types.go, modules/cabana/contracts.go, modules/cabana/http.go, modules/cabana/admin_openapi.go, modules/cabana/README.md, modules/cabana/security_coverage_test.go, modules/cabana/openapi_conformance_test.go, admin/openapi/admin.json, admin/src/api/schema.d.ts, admin/tests/fixtures/widgets.list-schema.json, admin/tests/fixtures/widgets.form-schema.json, admin/tests/fixtures/settings.json, ../fonoteka.go/plugins/golem15/fonoteka/admin_phase09_security_test.go + modules/boardwalk/boardwalk.go, modules/boardwalk/README.md, modules/cabana/extension.go (Task 1), modules/cabana/actions.go (Task 1), modules/cabana/list_schema.go (toolbarButtons, compileToolbarButtons, compileList, ListSchema.Localize), modules/cabana/registry.go (withoutAction), modules/cabana/schema_types.go (ListSchema.MarshalJSON), modules/cabana/http.go (listSchema, formSchema, settingsSchema, serveSPA), modules/cabana/messages_test.go (toolbar cases), modules/cabana/list_schema_test.go ("unsupported action"), admin/tests/fixtures/typed.ts, ../fonoteka.go/plugins/golem15/fonoteka/admin_phase10_tracer_test.go (dist shell script under /plytadmin/assets), .planning/phases/10.1-runtime-admin-extension-point/10.1-RESEARCH.md (Pattern 5, Pitfalls 1, 5, 9) + (1) boardwalk: export `ContentType(name string) string` and `SetSecurityHeaders(h http.Header)` (rename the unexported helpers; the handler calls the exported names) and add both to modules/boardwalk/README.md API reference. + +(2) Assets (D-13, D-15, D-16): in extension.go, when the controller implements pact.AdminClientAssets, each declared path must equal path.Clean of itself, start with `assets/`, contain no `..` segment and not repeat; AdminJS entries end in `.js` or `.mjs`, AdminCSS entries in `.css`. Read each file from the plugin's AdminFS (so it must be in the plugin's embed list; a missing file fails boot naming it), hash it with crypto/sha256, and store it in an unexported Registry map keyed `vendor/plugin/` with body, Content-Type from boardwalk.ContentType and ETag as the quoted hex digest; identical keys from two controllers of one plugin share one entry; the owning plugin ID must be `vendor.plugin` with segments matching `[A-Za-z0-9_-]+`. CompiledController keeps its ordered script and style keys. A form with any widget whose controller declares no AdminJS file fails boot. Files always come from embed.FS; add no disk-override switch or config key (D-15). + +(3) New modules/cabana/plugin_assets.go with `(*service).pluginAsset`, mounted inside the public prefix GroupRaw as `g.Get("/assets/{vendor}/{plugin}/{file...}", s.pluginAsset)` with `g.Where("vendor", "[A-Za-z0-9_-]+")` and `g.Where("plugin", "[A-Za-z0-9_-]+")`. On an exact key hit: boardwalk.SetSecurityHeaders, `Cross-Origin-Resource-Policy: same-origin`, the stored Content-Type, `Cache-Control: no-cache`, the ETag, then http.ServeContent over the stored bytes (it answers If-None-Match with 304 and serves HEAD). Plugin files are not content-hashed, so boardwalk's one-year caching rule for its hashed dist files must not apply to them. On a miss call s.serveSPA(w, r), so Vite's flat dist `assets/*` still loads and an undeclared plugin file (YAML, template) is the SPA's 404. schema_types.go gains `ControllerAssets` (`Scripts []string json:"scripts"`, `Styles []string json:"styles"`, always arrays) as `Assets` on ListSchema (`json:"assets"`) and FormView (`json:"assets"`); the listSchema and formSchema handlers fill them with `{s.adminPrefix()}/assets/{key}?v={first 12 hex chars}`; settings schemas carry empty arrays. + +(4) Toolbar (D-12): in list_schema.go toolbarButtons.UnmarshalYAML keeps the string, duplicate and scalar-rejection checks but drops the membership test (decode has no controller); non-string entries read "toolbar.buttons entries must be action names". `compileToolbarButtons` gains the controller: each name is `create`, `delete` (still needing showCheckboxes) or a name from the controller's pact.HasAdminActions; anything else fails with "toolbar.buttons: unsupported action NAME (want create, delete or an action the controller registers)" (keep the phrase "unsupported action NAME" that messages_test.go and list_schema_test.go assert); a registered action listed in toolbar.buttons needs a non-empty Label. ListSchema gains `ToolbarActions []ToolbarAction json:"toolbarActions"` (`Name json:"name"`, `Label json:"label"`) in declared order, always an array in MarshalJSON, labels localized in ListSchema.Localize; the listSchema handler keeps only actions whose Permissions pass Allows for the principal; withoutAction still drops only create. In actions.go add `(*service).toolbarAction`, mounted `g.Post("/{vendor}/{plugin}/{controller}/toolbar/{action}", requireAjax(s.toolbarAction))` plus constrainController(g) and `g.Where("action", "[A-Za-z_][A-Za-z0-9_]*")`: s.protect; the name must be a custom name in cc.List.ToolbarButtons and in cc.Actions (else 404); action permissions (403); decodeActionRequest, and a body carrying record_id or values is a 422; Run with an empty Field and nil RecordID; Fill is always `{}`; Message translated; 200. Widgets and the toolbar share cc.Actions (assumption-delta decision). + +(5) OpenAPI annotation `AdminToolbarAction` (path params vendor, plugin, controller, action; body AdminActionRequest; 200 Envelope[AdminActionResult]; 401, 403, 404, 422). Regenerate with scripts/check-admin-openapi.sh. The new required list and form keys break the typed JSON fixtures, so add `"assets": {"scripts": [], "styles": []}` to widgets.list-schema.json, widgets.form-schema.json and the schema in settings.json and `"toolbarActions": []` to widgets.list-schema.json, and fix any other admin/tests file vue-tsc reports; do not touch admin/src in this plan. + +(6) Inventories and fixture in the same commit: phase09Routes gains `POST /{vendor}/{plugin}/{controller}/toolbar/{action}` and `{key: "GET /assets/{vendor}/{plugin}/{file...}", public: true, spa: true}`; phase09ProtectedCalls gains `{"toolbar-action", (*service).toolbarAction}`. conformController gains `AdminJS` returning `assets/js/lookup.js`, `AdminCSS` returning `assets/css/gadgets.css` and action `recount` (Label "Recount", Permissions acme.conform.access, Run returning Message "Recounted"); conformFS gains `assets/js/lookup.js` (a plain custom element `acme-conform-lookup` with a light-DOM button that dispatches a bubbling, composed `summer-action` event, with no network call and no cookie access) and `assets/css/gadgets.css`; config_list.yaml buttons become `[create, delete, recount]`. Add the conformance case `POST /{vendor}/{plugin}/{controller}/toolbar/{action}` (200, `cabana.Envelope-cabana_AdminActionResult`) posting `{}` to `/acme/conform/gadgets/toolbar/recount`. In the fonoteka.go route list add `{"POST " + adminAPI("/{vendor}/{plugin}/{controller}/toolbar/{action}"), false}` (fonoteka.go commit). + +(7) modules/cabana/README.md: sections for controller assets (the AdminClientAssets contract, URL layout, caching and the embed-only rule) and toolbar actions (registration, reserved create/delete, permission-filtered toolbarActions), plus route rows; boardwalk README lists the two exports. + + go vet ./... && go test ./modules/boardwalk ./modules/cabana -count=1 && go test ./modules/cabana -run '^(TestPhase09PermissionMatrix|TestPhase09ContractInventory|TestPhase10OpenAPIConformance|TestPhase10CSRF|TestPhase10Toolbar|TestPhase10Messages|TestListSchemaRejects)$' -count=1 -v && scripts/check-admin-openapi.sh --check && npm --prefix admin run typecheck && npm --prefix admin test -- tests/smoke && (cd ../fonoteka.go && go test ./plugins/golem15/fonoteka -run '^(TestPhase09SecurityRoutes|TestPhase10TracerSPA|TestPhase10ControllerCopy|TestAlbumsAdminList)$' -count=1 -v) + Any command exits non-zero; a verbose run lacks a "--- PASS" line for any test named in its -run pattern, or prints "no tests to run" or "--- SKIP"; vitest prints "No test files found" or a "FAIL" line; check-admin-openapi.sh prints a diff. + + + - `go doc ./modules/boardwalk ContentType` and `go doc ./modules/boardwalk SetSecurityHeaders` exit 0. + - `grep -c 'requireAjax(s.toolbarAction)' modules/cabana/http.go` prints 1 and `grep -c '/assets/{vendor}/{plugin}/{file...}' modules/cabana/http.go` prints 1. + - `grep -c 'immutable' modules/cabana/plugin_assets.go` prints 0. + - TestPhase10TracerSPA (fonoteka) still loads the dist shell script under /plytadmin/assets, proving the miss fall-through. + - TestPhase10OpenAPIConformance covers the toolbar route, and its list-schema case decodes `assets` and `toolbarActions`. + - The existing toolbar cases in messages_test.go ("unsupported action export", duplicate, showCheckboxes, scalar) and list_schema_test.go ("drop_database") pass unchanged. + + A controller's declared JS/CSS is served from its embedded files at a hashed same-origin URL named in the list and form schemas, and a registered toolbar action runs from its cabana route with permission filtering, while create and delete behave as before. + + + + Task 3: Header partials and form partials render server-side into an allowlisted node tree + go.mod, go.sum, modules/cabana/form_schema.go, modules/cabana/form_schema_test.go, modules/cabana/list_schema.go, modules/cabana/settings.go, modules/cabana/extension.go, modules/cabana/partial_render.go, modules/cabana/schema_types.go, modules/cabana/contracts.go, modules/cabana/http.go, modules/cabana/admin_openapi.go, modules/cabana/README.md, modules/cabana/security_coverage_test.go, modules/cabana/openapi_conformance_test.go, admin/openapi/admin.json, admin/src/api/schema.d.ts, ../fonoteka.go/plugins/golem15/fonoteka/admin_phase09_security_test.go + modules/cabana/form_schema.go, modules/cabana/form_schema_test.go (cases "partial", "partial path", "bad form fails activation"), modules/cabana/list_schema.go (listDocument, compileList), modules/cabana/extension.go and modules/cabana/actions.go (Tasks 1-2; readScopedRecord), modules/cabana/crud.go (partialSelection name, pathID), modules/cabana/http.go (formSchema handler shape), modules/cabana/schema_types.go, go.mod, .planning/phases/10.1-runtime-admin-extension-point/10.1-RESEARCH.md (Pattern 4, Pitfalls 2, 10, 13, Code Examples "Partial render + allowlist"), .planning/phases/10.1-runtime-admin-extension-point/10.1-UI-SPEC.md (Partial style kit markup) + (1) YAML (D-09, D-11): in form_schema.go add `partial` to formFieldTypes and `path` to formFieldKeys, and delete the Phase 9 rejection of the partial type in compileFieldNode. `path` is valid only on type partial; type partial requires it; the value must be an identifier, so a Winter `$/` or `~/` path, or anything containing `/` or `.`, fails with "path must be a partial name such as summary (resolves to CONFIG_DIR/_summary.htm); Winter $/ and ~/ paths are not supported". Keep the words "partial" and "path" in these messages and update form_schema_test.go so its "partial" case (bare type partial without path), "partial path" case (the `$/` path) and "bad form fails activation" case still fail boot for the new, correctly named reasons. FormField gains `Path string json:"path,omitempty"`. In list_schema.go listDocument gains `HeaderPartial string yaml:"headerPartial"` (identifier or boot error) and ListSchema gains `HeaderPartial string json:"headerPartial,omitempty"`. settings.go refuses type partial as it refuses type widget. + +(2) Boot: extension.go compiles every declared partial name (the list's headerPartial and each partial field's path) once per controller: read `{ConfigDir}/_{name}.htm` from the plugin AdminFS (missing file fails boot naming it), require that the controller implements pact.AdminPartialData (else boot error), and parse the source with `template.New(name).Funcs(template.FuncMap{"trans": }).Parse` from html/template (parse errors fail boot). Store the pristine templates on CompiledController in an unexported map plus the set of names declared by form partial fields. Name the new types compiledPartial, PartialNode and PartialView, away from crud.go's partialSelection (Pitfall 13). + +(3) Render (D-10, D-17) in new modules/cabana/partial_render.go: `(*compiledPartial).render(ctx, tr, data)` Clones the pristine template (never executed, because Clone fails after Execute), binds `trans` with `.Funcs` to `translateKey(ctx, tr, key)`, Executes with root `map[string]any{"Data": data}` into a writer that fails past `partialMaxBytes` (64 << 10), parses the output with golang.org/x/net/html ParseFragment in a div context, and walks it into []PartialNode with a budget of `partialMaxNodes` (2000) nodes and `partialMaxDepth` (32); exceeding any cap is an error, never a truncated tree. Allowlist (RESEARCH Pattern 4, mirrored later by the SPA): tags div span p strong em b i u s small mark code pre br hr ul ol li dl dt dd h2 h3 h4 h5 h6 table thead tbody tfoot tr th td caption section header footer figure figcaption blockquote q abbr time data meter progress sup sub a img; global attributes class, title, lang, dir, role, aria-* and data-*; per tag: a[href] only when it starts with exactly one "/" (not "//" or "/\") or with "#"; img[src] only a same-origin "/" path (same rule), plus alt, width, height; td and th colspan, rowspan, scope; time datetime; data value; meter value, min, max, low, high, optimum; progress value, max. Drop id, style and every on* attribute. Drop with their whole subtree: script style template iframe object embed noscript textarea title xmp svg math form input button select link meta base. Unwrap any other element (keep its children). Drop comments and doctypes; text nodes become `{text}`. Model guard: when the view model's type, after dereferencing pointers and taking the element type of slices, arrays and maps, equals the type of the controller's NewRecord(), refuse (500). Run `go mod tidy` so golang.org/x/net becomes a direct requirement (already v0.58.0; the go.sum module set must not grow). + +(4) Route: `(*service).partial`, mounted in the backend GroupRaw as `g.Get("/{vendor}/{plugin}/{controller}/partials/{name}", s.partial)` plus constrainController(g) and `g.Where("name", "[A-Za-z_][A-Za-z0-9_]*")`. Order: s.protect; the name must be a declared partial (else 404); query `id` absent means a nil record (header partials, and form partials on create); when present it must be a positive integer and the name must belong to a form partial field (else 404), and the record comes from readScopedRecord (out of scope is 404); call PartialData(ctx, name, record) (an error is logged and answered 500 generic); apply the model guard; render (an error, including a cap, is logged with the controller and partial name and answered 500 generic); `WriteData(w, 200, PartialView{Nodes}, nil)` with Nodes always an array. schema_types.go gains `PartialNode` (`Tag string json:"tag,omitempty"`, `Attrs map[string]string json:"attrs,omitempty"`, `Text string json:"text,omitempty"`, `Children []PartialNode json:"children,omitempty"`) and `PartialView` (`Nodes []PartialNode json:"nodes"`). + +(5) OpenAPI annotation `AdminPartial` (path params vendor, plugin, controller, name; `@Param id query integer false "Record id for a form partial"`; 200 Envelope[PartialView]; 401, 403, 404). Regenerate and confirm schema.d.ts declares cabana.PartialNode with a recursive children array. + +(6) Inventories and fixture: phase09Routes gains `GET /{vendor}/{plugin}/{controller}/partials/{name}` and phase09ProtectedCalls gains `{"partial", (*service).partial}`. conformFS gains `controllers/gadgets/_stats.htm` (a `
` with one `summer-stat` item whose label is `{{ trans "backend::lang.list.search" }}` and whose value is `{{ .Data.Total }}`) and `controllers/gadgets/_summary.htm` (a `

` printing `{{ .Data.Name }}`), config_list.yaml `headerPartial: stats`, and fields.yaml `summary: {label: Summary, type: partial, path: summary}`. conformController implements PartialData: `stats` returns a struct with Total, `summary` returns a struct with the record's Name (empty on a nil record), anything else an error. Add the conformance case for the partial route (200, `cabana.Envelope-cabana_PartialView`) on `/acme/conform/gadgets/partials/summary?id=`. In the fonoteka.go route list add `{"GET " + adminAPI("/{vendor}/{plugin}/{controller}/partials/{name}"), false}` (fonoteka.go commit). + +(7) modules/cabana/README.md: a partials section (template location `{ConfigDir}/_{name}.htm`, the `trans` function and `.Data` root, the curated view model rule, the allowlist, the caps), the route row, and Dependencies gaining `golang.org/x/net/html`; check every identifier with go doc. + + go vet ./... && go test ./... -count=1 && go test ./modules/cabana -run '^(TestPhase09PermissionMatrix|TestPhase09ContractInventory|TestPhase10OpenAPIConformance|TestPhase10CSRF|TestPhase10Coverage|TestFormSchemaRejects|TestListSchemaRejects)$' -count=1 -v && scripts/check-admin-openapi.sh --check && npm --prefix admin run typecheck && scripts/check-phase10.sh --hygiene && (cd ../fonoteka.go && go vet ./... ./plugins/golem15/fonoteka/... && go test ./plugins/golem15/fonoteka/... -count=1) + Any command exits non-zero; a verbose run lacks "--- PASS" for TestPhase10OpenAPIConformance, TestPhase09ContractInventory, TestPhase09PermissionMatrix, TestFormSchemaRejects or TestListSchemaRejects, or prints "no tests to run" or "--- SKIP"; check-admin-openapi.sh prints a diff; check-phase10.sh prints a line starting with "refuse:". + + + - `grep -c 'html.ParseFragment' modules/cabana/partial_render.go` prints at least 1 and `grep -E '^\s+golang.org/x/net v' go.mod | grep -vc indirect` prints 1. + - `grep -c 'type partial is not supported' modules/cabana/form_schema.go` prints 0. + - `go doc ./modules/cabana PartialNode` and `go doc ./modules/cabana PartialView` exit 0, and `grep -c 'partials/{name}' modules/cabana/README.md` prints at least 1. + - `grep -c 'cabana.PartialNode' admin/src/api/schema.d.ts` prints at least 1. + - The form_schema_test.go "partial", "partial path" and "bad form fails activation" cases still exist and pass. + - The conformance partial case decodes into cabana.Envelope[cabana.PartialView] with unknown fields disallowed. + + A header partial and a form partial on the acme fixture render through html/template into an allowlisted, capped node tree served by a cabana route, the whole framework test suite is green, and the admin OpenAPI document types every new route. + + + + +## Source coverage (this plan) + +| Source | Item | Task | +|--------|------|------| +| CONTEXT | D-05 SPA owns HTTP; cabana-owned POST with CSRF | 1 (widget), 2 (toolbar) | +| CONTEXT | D-06 `type: widget` keys, unknown keys fail boot | 1 | +| CONTEXT | D-07 fill write-back (server filter) | 1 | +| CONTEXT | D-09 widget type added, partial type lifted | 1, 3 | +| CONTEXT | D-10 html/template, curated view model, escaping on | 3 | +| CONTEXT | D-11 headerPartial, missing template fails boot | 3 | +| CONTEXT | D-12 registered toolbar actions, unknown fails boot | 2 | +| CONTEXT | D-13 Go method for JS/CSS, not AdminAssets | 1 (contract), 2 (serving) | +| CONTEXT | D-15 embed.FS only | 2 | +| CONTEXT | D-16 same-origin under prefix, CSP unchanged | 2 | +| CONTEXT | D-17 server half: node tree | 3 | +| CONTEXT | D-01 / D-03 framework proof on a nameless fixture (form partial proven by acme `summary`) | 1, 2, 3 | +| RESEARCH | Pitfalls 1, 2, 5, 9, 10, 13, 14; Patterns 1-5, 7 | 1-3 | + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| Browser (admin cookie) → cabana action routes | Unsafe POSTs that run plugin Go code with a record id and fill values | +| Browser → plugin asset route (public) | Unauthenticated GETs under the admin prefix that read from plugin embed trees | +| Controller view model → html/template → node tree | Record data crosses into markup that the SPA later renders | +| Plugin YAML and Go registration → cabana boot | Plugin-declared names, tags, paths and permissions become routes and schema | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-10.1-01 | Information Disclosure | cabana plugin asset route | high | mitigate | Exact-key allowlist built at boot from declared `assets/` paths only; a miss falls through to the SPA handler; the plugin AdminFS is never served directly, so YAML and templates cannot leak and traversal matches no key (Task 2). | +| T-10.1-02 | Tampering | plugin asset responses (MIME sniffing) | medium | mitigate | Explicit JavaScript/CSS Content-Type from boardwalk.ContentType, nosniff, CSP and Cross-Origin-Resource-Policy same-origin on every hit (Task 2). | +| T-10.1-03 | Tampering | stale plugin JS after a rebuild | low | mitigate | `?v=` sha256 prefix in schema URLs, `Cache-Control: no-cache` and ETag revalidation; never immutable (Task 2). | +| T-10.1-04 | Tampering | widget and toolbar POST routes (CSRF) | high | mitigate | Both mounted through requireAjax; TestPhase10CSRF walks every unsafe mounted route automatically (Tasks 1-2). | +| T-10.1-05 | Elevation of Privilege | action execution | high | mitigate | protect() enforces controller permissions, then the action's own Permissions via Allows (403); permission codes validated at boot by validatePermissions; toolbarActions filtered per admin (Tasks 1-2). | +| T-10.1-06 | Elevation of Privilege | record_id on widget POST and ?id= on partial GET (IDOR) | high | mitigate | Records load only through readScopedRecord, which applies FormExtendQuery; out of scope is 404; toolbar actions accept no ids (Tasks 1, 3). | +| T-10.1-07 | Tampering | fill write-back (mass assignment) | high | mitigate | Boot requires fill ⊆ cc.Writable scalar fields; the handler drops every non-fill key and nested value from both Values and result.Fill; a later save still runs ProjectWritableFields and model rules (Task 1). | +| T-10.1-08 | Tampering | partial output (XSS, server half) | high | mitigate | html/template contextual escaping of view-model data, then x/net/html parse and a tag/attribute/URL allowlist into a JSON node tree; no HTML string leaves the server (Task 3). | +| T-10.1-09 | Information Disclosure | partial view models | medium | mitigate | AdminPartialData contract documents a curated view model; the handler refuses a view model of the controller's model type; records are scoped by cabana, not loaded by the plugin (Task 3). | +| T-10.1-11 | Tampering | custom-element name collisions across plugins | low | mitigate | Boot enforces the valid-name regex, the `{vendor}-{plugin}-` prefix of the owning plugin and the reserved-name list (Task 1). | +| T-10.1-12 | Denial of Service | partial rendering | medium | mitigate | 64 KiB output, 2000 nodes and depth 32 caps; exceeding one is a logged 500, never a partial render (Task 3). | +| T-10.1-SC | Tampering | Go and npm dependencies | high | mitigate | No npm change; golang.org/x/net promoted from an existing go.sum entry (named by RESEARCH); swag stays pinned at v1.16.6 via check-admin-openapi.sh. | + + + +After Task 3: `go vet ./... && go test ./...` in summercms.go, `go test ./plugins/golem15/fonoteka/...` in fonoteka.go, `scripts/check-admin-openapi.sh --check`, `npm --prefix admin run typecheck` and `scripts/check-phase10.sh --hygiene` all pass. The acme fixture proves widget, toolbar action, header partial, form partial and assets end to end through TestPhase10OpenAPIConformance. + + + +- pact exposes the six capability contracts; cabana compiles widget, partial, headerPartial and custom toolbar YAML with fail-closed boot rules. +- The three API routes and the asset route are mounted, permission and CSRF protected as specified, typed in admin/openapi/admin.json and covered by the inventories and conformance test in both repositories. +- Partials render through html/template into an allowlisted, capped node tree; the asset route serves only declared files. +- No application names in summercms.go; READMEs of pact, cabana and boardwalk updated in the same commits as their API changes. + + + +Create `.planning/phases/10.1-runtime-admin-extension-point/10.1-01-SUMMARY.md` when done. + diff --git a/.planning/phases/10.1-runtime-admin-extension-point/10.1-02-PLAN.md b/.planning/phases/10.1-runtime-admin-extension-point/10.1-02-PLAN.md new file mode 100644 index 0000000..94cb414 --- /dev/null +++ b/.planning/phases/10.1-runtime-admin-extension-point/10.1-02-PLAN.md @@ -0,0 +1,329 @@ +--- +phase: 10.1-runtime-admin-extension-point +plan: 02 +type: execute +wave: 2 +depends_on: [10.1-01] +files_modified: + - admin/src/app/pluginAssets.ts + - admin/src/components/form/formContext.ts + - admin/src/components/form/fields/WidgetField.vue + - admin/src/components/form/fields/PartialField.vue + - admin/src/components/partial/PartialHost.vue + - admin/src/components/partial/partialNodes.ts + - admin/src/components/form/registry.ts + - admin/src/components/form/FormField.vue + - admin/src/components/list/ListToolbar.vue + - admin/src/views/FormView.vue + - admin/src/views/ListView.vue + - admin/src/api/types.ts + - admin/src/styles/main.css + - admin/vite.config.ts + - admin/tests/fixtures/extension.form-schema.json + - admin/tests/fixtures/extension.list-schema.json + - admin/tests/fixtures/extension.partial.json + - admin/tests/fixtures/typed.ts + - admin/tests/smoke/extension.smoke.test.ts + - modules/phrasebook/backend/lang/en/lang.yaml + - modules/phrasebook/backend/lang/pl/lang.yaml + - modules/cabana/README.md + - modules/boardwalk/dist/** +autonomous: true +requirements: [ADMIN-07] +estimate: + tokens: 120000 + raw_tokens: 120000 + tasks: 3 + confidence: low +must_haves: + truths: + - "Per D-04, D-05 and D-08, a `type: widget` field loads its controller's scripts, waits for customElements.whenDefined with a 5000 ms timeout, creates the element imperatively and sets attributes only (record-id, empty on create; field-name; locale; fill-values as JSON of the current fill values kept in sync; label from the action label; busy-label); the element receives no token, cookie, Vue instance or function." + - "Per D-05 and D-07, a bubbling `summer-action` event from the element makes the SPA POST {record_id, values} to .../widgets/{field} through the typed API client with the admin cookie and X-Requested-With; repeat events are ignored while `busy` is set; on success only keys in field.fill that are present in the response fill are patched onto the form, the form becomes dirty, those fields' errors clear, nothing is saved, and result.message is a success toast; on failure a danger toast shows the server message or backend::lang.extension.action_failed and the element gets state=\"error\"." + - "Per D-09, widget and partial fields are registered renderers that are never part of the save body and render on create and update; relation-manager keeps its record-only rule." + - "Per D-17, PartialHost fetches .../partials/{name} (with ?id= on an existing record for form partials) and builds the node tree with Vue h() under the same tag, attribute and URL allowlist as the server; unknown tags are unwrapped, unknown or event attributes are dropped, text stays text, and admin/src contains no raw-HTML sink." + - "Per D-03 and D-11, when the list schema names a headerPartial the ListView renders it between the page header and the list card; it refetches after a successful bulk delete or custom toolbar action and not on search, filter, sort or page changes." + - "Per D-12, custom toolbar names present in schema.toolbarActions render in the ListToolbar after the built-ins in declared order as outline buttons labelled from toolbarActions, enabled regardless of the selection; a click disables the button with aria-busy, POSTs {} to .../toolbar/{action}, toasts result.message, reloads the list and refetches the header partial; a failure is a danger toast; a name the admin may not run is never rendered; create and delete behave as in Phase 10." + - "Per D-14 and D-16, plugin scripts and styles load only when their controller's list or form opens, only from URLs under {runtime.base}/assets/, once per URL (a failed script can retry on a later navigation), and stylesheet links of other controllers are disabled; the list table never waits for plugin JS." + - "UI consideration (loading S1): the first header-partial fetch shows one full-width 80px radius-16 bg-skel block (aria-hidden) with aria-busy on the host; refetches keep the previous nodes visible with aria-busy and no skeleton flash." + - "UI consideration (empty S1): a header partial with zero nodes renders nothing and takes no gap." + - "UI consideration (error S1): a header partial failure renders the full-width extension failure box with backend::lang.extension.partial_failed and the list stays usable." + - "UI consideration (overflow S1 stats items): the .summer-stats kit wraps items with flex-wrap, a 32px column gap and an 8px row gap inside one card and never scrolls horizontally." + - "UI consideration (overflow S1/S2 partial output): a server 500 for an oversized partial shows the partial_failed box, never a truncated render." + - "UI consideration (loading S2): a form partial shows a 44px radius-10 bg-skel bar until its nodes arrive." + - "UI consideration (error S2): a form partial failure shows the extension failure box with partial_failed in its row and the form stays saveable." + - "UI consideration (empty S2): a form partial with zero nodes shows only its label, if declared, and no placeholder text; with no label there is no label row." + - "UI consideration (partial S2/S3 on create): form partials and widgets render on create; the partial is fetched without ?id= and the widget gets record-id=\"\"." + - "UI consideration (loading S3): a widget shows a 42px by 160px radius-10 bg-skel bar with aria-busy on its group until whenDefined resolves or 5000 ms pass." + - "UI consideration (error S3 load): on a script error or timeout the element is not mounted and the widget_failed failure box is shown." + - "UI consideration (error S3 POST): a failed action POST shows a danger toast (server message or action_failed), sets state=\"error\" and leaves the form values untouched." + - "UI consideration (loading S3 in flight): while the POST runs the element carries the busy attribute and repeat summer-action events are ignored." + - "UI consideration (form S3 fill write-back): only field.fill keys present in result.fill are patched; the form becomes dirty, their errors clear, and nothing saves until Save." + - "UI consideration (empty S3 with no fill values): the element mounts with fill-values `{}` and record-id \"\" and the framework never hides or disables it." + - "UI consideration (loading/error S4): a custom toolbar button is disabled with aria-busy during its POST; success toasts then reloads the list and refetches the partial; failure toasts danger." + - "UI consideration (long-text S2): .summer-partial sets overflow-wrap: anywhere so long words and URLs wrap inside the row." + - "UI consideration (long-text/overflow S5): the extension failure box text wraps and the box grows past min-h-input, switching to items-start with 10px vertical padding when multi-line, so the pl widget_failed copy is fully readable." + - statement: "UI consideration (error S6, CSS bleed across controllers): stylesheet links of inactive controllers are disabled; the pluginAssets toggle is covered here and in 10.1-04, and a real-browser check (RESEARCH A4) confirms module scripts load under CSP script-src 'self'." + verification: backstop + artifacts: + - path: "admin/src/app/pluginAssets.ts" + provides: "Idempotent per-controller script and stylesheet loader with the same-origin prefix check" + exports: ["loadScript", "loadStyles", "activateStyles", "loadControllerAssets"] + - path: "admin/src/components/form/fields/WidgetField.vue" + provides: "Custom-element host bridging summer-action to the typed POST, fill patch and toast" + - path: "admin/src/components/partial/PartialHost.vue" + provides: "Header and form partial host with loading, empty and error states" + - path: "admin/src/components/partial/partialNodes.ts" + provides: "Client allowlist and h() renderer for PartialNode trees" + - path: "admin/src/components/form/formContext.ts" + provides: "InjectionKeys for form values, patch and locale" + - path: "modules/boardwalk/dist/index.html" + provides: "Rebuilt embedded SPA containing the new hosts" + key_links: + - from: "admin/src/components/form/fields/WidgetField.vue" + to: "POST /{vendor}/{plugin}/{controller}/widgets/{field}" + via: "typed openapi-fetch api.POST" + pattern: "widgets/\\{field\\}" + - from: "admin/src/components/partial/PartialHost.vue" + to: "GET /{vendor}/{plugin}/{controller}/partials/{name}" + via: "typed openapi-fetch api.GET" + pattern: "partials/\\{name\\}" + - from: "admin/src/views/ListView.vue" + to: "POST /{vendor}/{plugin}/{controller}/toolbar/{action}" + via: "onAction handler" + pattern: "toolbar/\\{action\\}" + - from: "admin/src/components/form/registry.ts" + to: "WidgetField.vue and PartialField.vue" + via: "widget and partial renderer registration plus the valueless set" + pattern: "'(widget|partial)'" + prohibitions: + - "No raw-HTML sink and no HTML-string parser anywhere in admin/src, including comments (the Phase 10 hygiene list)." + - "No direct network call outside admin/src/api/client.ts; plugin assets load only through script and link elements." + - "No npm package is added or re-pinned." +--- + +## Phase Goal + +A plugin extends the compiled admin SPA without a Node rebuild: controller JS/CSS served same-origin from embedded files, `type: widget` custom elements whose actions the SPA posts, `type: partial` and list `headerPartial` rendered server-side without a raw-HTML sink, and registered toolbar actions (ADMIN-07). + + +Build the SPA half of the extension point in summercms.go/admin: the plugin asset loader, the widget host, the partial host for form and list-header partials, custom toolbar buttons, the partial style kit and the new framework strings, then rebuild and commit the embedded dist. + +Purpose: This is the only Node-built part of the phase; after it, application plugins extend the admin with Go, YAML, templates and plain JS alone (D-04). Decisions implemented: D-04, D-05, D-07 (client patch), D-08, D-09, D-12 (SPA), D-14, D-16 (loader prefix check), D-17 (client half); UI-SPEC surfaces S1 to S6. +Output: new SPA modules and components, updated form and list views, the `backend::lang.extension.*` strings, smoke tests importing every new module, rebuilt `modules/boardwalk/dist`. + +Repo: summercms.go only. Code and planning docs in separate commits; never add co-author tags. + + + +@~/.claude/gsd-core/workflows/execute-plan.md +@~/.claude/gsd-core/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/STATE.md +@.planning/phases/10.1-runtime-admin-extension-point/10.1-CONTEXT.md +@.planning/phases/10.1-runtime-admin-extension-point/10.1-UI-SPEC.md +@.planning/phases/10.1-runtime-admin-extension-point/10.1-RESEARCH.md +@.planning/phases/10.1-runtime-admin-extension-point/10.1-01-SUMMARY.md +@.planning/phases/10-admin-vue-spa/design/README.md +@admin/src/api/schema.d.ts +@admin/src/components/form/registry.ts +@admin/src/views/FormView.vue +@admin/src/views/ListView.vue + + +From 10.1-01 (typed in admin/src/api/schema.d.ts after its regeneration): +- `POST /{vendor}/{plugin}/{controller}/widgets/{field}` body `cabana.AdminActionRequest` {record_id?: number; values?: Record} → `cabana.Envelope-cabana_AdminActionResult` {data: {message: string; fill: Record}}; 403, 404, 422 are `cabana.ErrorEnvelope`. +- `POST /{vendor}/{plugin}/{controller}/toolbar/{action}` body `{}` → same envelope, fill always `{}`. +- `GET /{vendor}/{plugin}/{controller}/partials/{name}` query `id?` → `cabana.Envelope-cabana_PartialView` {data: {nodes: cabana.PartialNode[]}}; PartialNode {tag?: string; attrs?: Record; text?: string; children?: PartialNode[]}. +- `cabana.FormField` gains widget?, action?, actionLabel? (localized), fill?, path?; `cabana.FormView.assets` and `cabana.ListSchema.assets` are `cabana.ControllerAssets` {scripts: string[]; styles: string[]} with absolute URLs `{base}/assets/...?v=`; `cabana.ListSchema.headerPartial?`; `cabana.ListSchema.toolbarActions` is `cabana.ToolbarAction[]` {name; label} already filtered to what the admin may run. +Existing SPA seams: `admin/src/components/form/control.ts` FieldControlProps {field, modelValue, controlId, invalid?, describedBy?, labels?, source?: ControllerParams | null, recordId?: number | null}; `registry.ts` renderers map, `isRegistered`, `needsRecord`, `ownsLabel` (field components import ../control, never ../registry); `formState.ts` editablePayload skips `!isRegistered(type)`; `app/runtime.ts` `runtime.base`; `app/i18n.ts` `t`, `message`, `currentLocale`; `state/useToasts.ts` `showToast(text, tone)`; `api/client.ts` `api` (the only allowed network call site); `components/ui/Button.vue` variants primary/outline/ghost/danger, size md/sm; `components/form/fields/UnsupportedField.vue` failure-box class string; tests use `tests/helpers.ts` (`mountApp`, `requestsTo`, API base `/admin-test/api/v1`) and typed fixtures in `tests/fixtures/typed.ts`. +Hygiene rules that must stay green (`scripts/check-phase10.sh --hygiene`): no raw-HTML directive words in admin/src even in comments; no direct network call outside api/client.ts; admin/src/api holds only schema.d.ts, client.ts and types.ts, and types.ts only aliases `Schemas['cabana.X']`; every .ts/.vue file in admin/src is imported by some test; no application names in admin/src, admin/tests or modules/cabana. + + + +## Planning notes + +- Spec-less probe fallback skipped: no requirement IDs were mapped for Phase 10.1 before this planning run; ADMIN-07 is introduced by it. Truths come from CONTEXT D-01..D-17 and UI-SPEC surfaces S1-S6; every resolved UI-SPEC "UI Considerations" row assigned to the SPA is a truth above (the S1 host "partial / zero-one-many" row was dismissed in the UI-SPEC and needs no must-have; Albums-specific rows live in 10.1-03). +- Discretion resolved: the widget also receives `label` and `busy-label` attributes (RESEARCH Open Question 4; additive, no credential) so plugin JS carries no strings; widgets render on create and update; the partial client allowlist lives in `partialNodes.ts` so 10.1-04 can unit-test it directly. +- Tests here are smoke tests (CLAUDE.md rule 3); branch-level Vitest suites are 10.1-04. + +## Artifacts this phase produces + +- `admin/src/app/pluginAssets.ts`: `loadScript(url)`, `loadStyles(controllerId, urls)`, `activateStyles(controllerId)`, `loadControllerAssets(controllerId, assets)` +- `admin/src/components/form/formContext.ts`: `FORM_VALUES`, `FORM_PATCH`, `FORM_LOCALE` InjectionKeys +- `admin/src/components/form/fields/WidgetField.vue`, `admin/src/components/form/fields/PartialField.vue` +- `admin/src/components/partial/PartialHost.vue` (props `source`, `name`, `recordId`, `variant: 'header' | 'field'`, `reloadKey`) +- `admin/src/components/partial/partialNodes.ts`: `PARTIAL_TAGS`, `partialAttrAllowed(tag, name, value)`, `renderPartialNodes(nodes)` +- registry.ts: `widget` and `partial` renderers, `valueless` set, exported `groupLabelled(type)` +- ListToolbar props `actions: ToolbarAction[]`, `busyAction: string | null`, event `action: [name]` +- types.ts aliases `AdminActionRequest`, `AdminActionResult`, `ControllerAssets`, `ToolbarAction`, `PartialNode`, `PartialView` +- CSS kit classes `.summer-partial`, `.summer-stats`, `.summer-stat`, `.summer-stat__label`, `.summer-stat__value` +- Phrase keys `backend::lang.extension.busy`, `.widget_failed`, `.partial_failed`, `.action_failed` (en, pl) +- Custom-element contract: event name `summer-action`; attributes `record-id`, `field-name`, `locale`, `fill-values`, `label`, `busy-label`, `busy`, `state` +- Vite dev proxy entry `${devPrefix}/assets` +- Fixtures `tests/fixtures/extension.form-schema.json`, `extension.list-schema.json`, `extension.partial.json`; smoke test `tests/smoke/extension.smoke.test.ts` + + + + + Task 1: An admin clicks a plugin widget on a form, and only its fill fields change before a toast confirms + D-05 and D-08 fix the element contract (attributes only, the summer-action event, the SPA owning HTTP) that every plugin widget is written against; user-locked, so flagged without a checkpoint. + 10.1-01 is executed: `grep -q 'widgets/{field}' admin/src/api/schema.d.ts && grep -q 'cabana.ControllerAssets' admin/src/api/schema.d.ts` succeeds. + admin/src/app/pluginAssets.ts, admin/src/components/form/formContext.ts, admin/src/components/form/fields/WidgetField.vue, admin/src/components/form/registry.ts, admin/src/components/form/FormField.vue, admin/src/views/FormView.vue, admin/src/api/types.ts, modules/phrasebook/backend/lang/en/lang.yaml, modules/phrasebook/backend/lang/pl/lang.yaml, admin/tests/fixtures/extension.form-schema.json, admin/tests/fixtures/typed.ts, admin/tests/smoke/extension.smoke.test.ts, modules/boardwalk/dist/** + admin/src/components/form/registry.ts, admin/src/components/form/control.ts, admin/src/components/form/FieldRenderer.vue, admin/src/components/form/FormField.vue, admin/src/components/form/FormGrid.vue, admin/src/components/form/formState.ts, admin/src/components/form/fields/UnsupportedField.vue, admin/src/views/FormView.vue, admin/src/views/ListView.vue (onDelete POST-toast idiom), admin/src/app/runtime.ts, admin/src/app/i18n.ts, admin/src/api/types.ts, admin/src/api/schema.d.ts, admin/tests/helpers.ts, admin/tests/fixtures/typed.ts, admin/tests/smoke/form.smoke.test.ts, modules/phrasebook/backend/lang/en/lang.yaml, modules/phrasebook/phase10_test.go (TestPhase10SPAKeysResolve), .planning/phases/10.1-runtime-admin-extension-point/10.1-UI-SPEC.md (S3, S5, Copywriting Contract) + (1) types.ts: add aliases `AdminActionRequest`, `AdminActionResult` and `ControllerAssets` onto `Schemas['cabana.X']` only (the hygiene regex accepts nothing else). + +(2) New admin/src/app/pluginAssets.ts (D-14, D-16): a module-level `Map>` of scripts. `loadScript(url)` rejects any URL that does not start with `${runtime.base}/assets/` (T-10.1-13), otherwise appends one `