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.
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 |
|
true |
|
|
|
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).
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 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/htmlis the dependency named by 10.1-RESEARCH (Standard Stack, Open Question 2) and approved by CONTEXT D-18; it is alreadygolang.org/x/net v0.58.0 // indirectin 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 keysFormField.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, constantspartialMaxBytes(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.yamlheaderPartial; 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}.htmwith atransfunction and root.Data - Fixture: acme conform plugin gains widget
lookup(tagacme-conform-lookup), toolbar actionrecount, partialsstatsandsummary, assetsassets/js/lookup.jsandassets/css/gadgets.css
(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.
(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.
(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> |
<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>