Files
summercms/.planning/phases/10.1-runtime-admin-extension-point/10.1-01-PLAN.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

52 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, estimate, must_haves
phase plan type wave depends_on files_modified autonomous requirements estimate must_haves
10.1-runtime-admin-extension-point 01 execute 1
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
true
ADMIN-07
tokens raw_tokens tasks confidence
150000 150000 3 low
truths artifacts key_links prohibitions
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: <name>` in config_list.yaml and `type: partial` with `path: <name>` in fields.yaml resolve to {ConfigDir}/_<name>.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.
path provides contains
modules/pact/capabilities.go AdminClientAssets, AdminAction, AdminActionInput, AdminActionResult, HasAdminActions, AdminPartialData type AdminPartialData interface
path provides
modules/cabana/extension.go Boot validation of widgets, actions, assets and partials per controller
path provides
modules/cabana/actions.go Widget and toolbar action handlers, strict body decoding, scoped non-locking record read
path provides
modules/cabana/plugin_assets.go Exact-allowlist plugin asset handler with SPA fall-through
path provides
modules/cabana/partial_render.go html/template partial rendering, x/net/html allowlist walk, size caps, partial GET handler
path provides
admin/openapi/admin.json Typed widgets, toolbar and partials operations and the PartialNode, PartialView, AdminActionRequest, AdminActionResult, ControllerAssets, ToolbarAction schemas
from to via pattern
modules/cabana/http.go modules/cabana/actions.go cabana-owned POST routes wrapped in requireAjax requireAjax(s.(widgetAction|toolbarAction))
from to via pattern
modules/cabana/extension.go modules/pact/capabilities.go type assertions for HasAdminActions, AdminClientAssets and AdminPartialData pact.(HasAdminActions|AdminClientAssets|AdminPartialData)
from to via pattern
modules/cabana/plugin_assets.go modules/boardwalk/boardwalk.go shared security headers and MIME map boardwalk.(SetSecurityHeaders|ContentType)
from to via pattern
modules/cabana/partial_render.go golang.org/x/net/html ParseFragment then allowlist walk html.ParseFragment
from to via pattern
modules/cabana/openapi_conformance_test.go admin/openapi/admin.json every inventoried route decoded into its documented type widgets/{field}
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.

<execution_context> @/.claude/gsd-core/workflows/execute-plan.md @/.claude/gsd-core/templates/summary.md </execution_context>

@.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) and approved by CONTEXT D-18; it is already golang.org/x/net v0.58.0 // indirect in go.mod, so promoting it adds no module (CLAUDE.md rule 4).

<assumption_delta_decision> 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. </assumption_delta_decision>

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 <Identifier>). 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) <fails_when>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".</fails_when> <acceptance_criteria> - 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. </acceptance_criteria> 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/<path after assets/> 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) <fails_when>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.</fails_when> <acceptance_criteria> - 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. </acceptance_criteria> 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": <placeholder>}).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 (D-18) (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 <dl class="summer-stats"> 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 <p> 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=<gadgetID>. 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) <fails_when>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:".</fails_when> <acceptance_criteria> - 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. </acceptance_criteria> 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

<threat_model>

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.
</threat_model>
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.

<success_criteria>

  • 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. </success_criteria>
Create `.planning/phases/10.1-runtime-admin-extension-point/10.1-01-SUMMARY.md` when done.