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

332 lines
52 KiB
Markdown

---
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: <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."
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).
<objective>
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.
</objective>
<execution_context>
@~/.claude/gsd-core/workflows/execute-plan.md
@~/.claude/gsd-core/templates/summary.md
</execution_context>
<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
<interfaces>
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.
</interfaces>
</context>
## 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`
<tasks>
<task type="tracer">
<name>Task 1: An acme widget field posts its registered action through cabana and gets back only its fill keys</name>
<reversibility rating="costly">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.</reversibility>
<precondition>Phases 10 and 10.2 are executed: `test -f modules/cabana/admin_openapi.go &amp;&amp; test -f scripts/check-phase10.sh &amp;&amp; test -f ../fonoteka.go/plugins/golem15/fonoteka/admin_phase09_security_test.go` succeeds.</precondition>
<files>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</files>
<read_first>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)</read_first>
<action>(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.</action>
<verify>
<automated>go vet ./... &amp;&amp; go test ./modules/pact ./modules/cabana -count=1 &amp;&amp; go test ./modules/cabana -run '^(TestPhase09PermissionMatrix|TestPhase09ContractInventory|TestPhase10OpenAPIConformance|TestPhase10CSRF|TestPhase10Coverage)$' -count=1 -v &amp;&amp; scripts/check-admin-openapi.sh --check &amp;&amp; npm --prefix admin run typecheck &amp;&amp; (cd ../fonoteka.go &amp;&amp; go test ./plugins/golem15/fonoteka -run '^(TestPhase09SecurityRoutes|TestPhase10Controllers|TestAlbumsAdminForm)$' -count=1 -v)</automated>
<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>
</verify>
<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>
<done>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.</done>
</task>
<task type="auto">
<name>Task 2: Controllers serve their own JS/CSS same-origin and register named toolbar actions</name>
<reversibility rating="costly">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.</reversibility>
<files>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</files>
<read_first>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)</read_first>
<action>(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.</action>
<verify>
<automated>go vet ./... &amp;&amp; go test ./modules/boardwalk ./modules/cabana -count=1 &amp;&amp; go test ./modules/cabana -run '^(TestPhase09PermissionMatrix|TestPhase09ContractInventory|TestPhase10OpenAPIConformance|TestPhase10CSRF|TestPhase10Toolbar|TestPhase10Messages|TestListSchemaRejects)$' -count=1 -v &amp;&amp; scripts/check-admin-openapi.sh --check &amp;&amp; npm --prefix admin run typecheck &amp;&amp; npm --prefix admin test -- tests/smoke &amp;&amp; (cd ../fonoteka.go &amp;&amp; go test ./plugins/golem15/fonoteka -run '^(TestPhase09SecurityRoutes|TestPhase10TracerSPA|TestPhase10ControllerCopy|TestAlbumsAdminList)$' -count=1 -v)</automated>
<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>
</verify>
<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>
<done>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.</done>
</task>
<task type="auto">
<name>Task 3: Header partials and form partials render server-side into an allowlisted node tree</name>
<files>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</files>
<read_first>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)</read_first>
<action>(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.</action>
<verify>
<automated>go vet ./... &amp;&amp; go test ./... -count=1 &amp;&amp; go test ./modules/cabana -run '^(TestPhase09PermissionMatrix|TestPhase09ContractInventory|TestPhase10OpenAPIConformance|TestPhase10CSRF|TestPhase10Coverage|TestFormSchemaRejects|TestListSchemaRejects)$' -count=1 -v &amp;&amp; scripts/check-admin-openapi.sh --check &amp;&amp; npm --prefix admin run typecheck &amp;&amp; scripts/check-phase10.sh --hygiene &amp;&amp; (cd ../fonoteka.go &amp;&amp; go vet ./... ./plugins/golem15/fonoteka/... &amp;&amp; go test ./plugins/golem15/fonoteka/... -count=1)</automated>
<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>
</verify>
<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>
<done>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.</done>
</task>
</tasks>
## 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>
<verification>
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.
</verification>
<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>
<output>
Create `.planning/phases/10.1-runtime-admin-extension-point/10.1-01-SUMMARY.md` when done.
</output>