--- phase: 10.1-runtime-admin-extension-point plan: 01 subsystem: api tags: [go, cabana, pact, boardwalk, html-template, x-net-html, openapi, swag, admin] requires: - phase: 10-admin-vue-spa provides: cabana admin API, requireAjax CSRF guard, boardwalk SPA serving, typed admin OpenAPI pipeline, conformance and inventory tests - phase: 10.2-nest-framework-packages-under-modules-and-write-run-docs provides: modules/ layout and module README rules provides: - pact contracts AdminClientAssets, AdminAction, AdminActionInput, AdminActionResult, HasAdminActions, AdminPartialData - fields.yaml type widget (widget, action, fill) and type partial (path); config_list.yaml headerPartial; registered toolbar.buttons names - POST {prefix}/api/v1/{vendor}/{plugin}/{controller}/widgets/{field} and .../toolbar/{action} - GET {prefix}/api/v1/{vendor}/{plugin}/{controller}/partials/{name} returning an allowlisted node tree - GET {prefix}/assets/{vendor}/{plugin}/{file...} exact-allowlist plugin asset route with SPA fall-through - list and form schema keys assets, toolbarActions, headerPartial and field keys widget, action, actionLabel, fill, path - exported boardwalk.ContentType and boardwalk.SetSecurityHeaders - typed OpenAPI operations and schemas (AdminActionRequest, AdminActionResult, ControllerAssets, ToolbarAction, PartialNode, PartialView) affects: [10.1-02 SPA extension seams, 10.1-03 Albums extension, 10.1-04 unit tests and gate, 14 Discogs] actuals: tokens: 39500 tasks: 3 commits: 3 plan_head_before: 9b98d8409fcfc25e815983ced43853f21ae3be64 plan_head_after: 771d2ccce0bf3357d4be1d200bebe906f5a6bb70 tech-stack: added: [golang.org/x/net/html (promoted from indirect to direct, no new module)] patterns: - "Cabana-owned action routes: plugins register Go Run functions; cabana owns the route, CSRF, permissions and record scope" - "Single action namespace: toolbar.buttons and widget action: both resolve through CompiledController.Actions; create and delete are reserved" - "Exact-key asset allowlist built at boot, miss falls through to the SPA handler" - "Server-sanitized partials: html/template Clone per request, ParseFragment, allowlist walk into JSON nodes with caps" key-files: created: - modules/cabana/extension.go - modules/cabana/actions.go - modules/cabana/plugin_assets.go - modules/cabana/partial_render.go modified: - modules/pact/capabilities.go - modules/pact/README.md - modules/boardwalk/boardwalk.go - modules/boardwalk/README.md - modules/cabana/form_schema.go - modules/cabana/list_schema.go - modules/cabana/schema_types.go - modules/cabana/contracts.go - modules/cabana/registry.go - modules/cabana/messages.go - modules/cabana/settings.go - modules/cabana/http.go - modules/cabana/admin_openapi.go - modules/cabana/README.md - modules/cabana/openapi_conformance_test.go - modules/cabana/security_coverage_test.go - admin/openapi/admin.json - admin/src/api/schema.d.ts - go.mod - ../fonoteka.go/plugins/golem15/fonoteka/admin_phase09_security_test.go - ../fonoteka.go/plugins/golem15/fonoteka/admin_collections_test.go key-decisions: - "An action Label containing :: is treated as a phrase key and must resolve at boot; other labels are literal button text" - "A toolbar action body must be {} exactly: record_id or values (even an empty values object) is a 422" - "The partial view-model guard also refuses types containing html/template trusted content types (template.HTML and siblings), enforcing the plan's no-raw-HTML prohibition" - "Partial and asset href/src URLs with whitespace or control characters are refused, since browsers strip them and could turn //host into a protocol-relative URL" - "The empty-body POST is a 422 like any malformed body; the SPA sends {}" patterns-established: - "compileExtension runs after BindWritableFields and owns every controller-level extension check (actions, widgets, assets, partials)" - "Per-request locale for action messages and partial trans through towel.WithLocale(schemaLocale(...))" requirements-completed: [ADMIN-07] coverage: - id: D1 description: "Widget field runs its registered action through POST .../widgets/{field}; the response fill holds only the declared fill keys" requirement: ADMIN-07 verification: - kind: integration ref: "modules/cabana/openapi_conformance_test.go#TestPhase10OpenAPIConformance/POST_/{vendor}/{plugin}/{controller}/widgets/{field}" status: pass - kind: unit ref: "modules/cabana/phase10_csrf_test.go#TestPhase10CSRF" status: pass - kind: unit ref: "modules/cabana/security_coverage_test.go#TestPhase09PermissionMatrix" status: pass human_judgment: false - id: D2 description: "Controller JS/CSS served at {prefix}/assets/{vendor}/{plugin}/... with explicit MIME, nosniff, CSP, CORP, no-cache and ETag; schema URLs carry ?v=; undeclared files fall through to the SPA 404" requirement: ADMIN-07 verification: - kind: integration ref: "modules/cabana/openapi_conformance_test.go#TestPhase10OpenAPIConformance/GET_/{vendor}/{plugin}/{controller}/schema/list (assertPluginAsset)" status: pass - kind: integration ref: "../fonoteka.go/plugins/golem15/fonoteka#TestPhase10TracerSPA" status: pass human_judgment: false - id: D3 description: "Registered toolbar actions: toolbar.buttons resolves create, delete and registered names; toolbarActions is permission-filtered; POST .../toolbar/{action} runs the action" requirement: ADMIN-07 verification: - kind: integration ref: "modules/cabana/openapi_conformance_test.go#TestPhase10OpenAPIConformance/POST_/{vendor}/{plugin}/{controller}/toolbar/{action}" status: pass - kind: unit ref: "modules/cabana/messages_test.go#TestPhase10Toolbar" status: pass - kind: unit ref: "modules/cabana/list_schema_test.go#TestListSchemaRejects" status: pass human_judgment: false - id: D4 description: "Header and form partials render through html/template into an allowlisted, capped node tree served by GET .../partials/{name}" requirement: ADMIN-07 verification: - kind: integration ref: "modules/cabana/openapi_conformance_test.go#TestPhase10OpenAPIConformance/GET_/{vendor}/{plugin}/{controller}/partials/{name}" status: pass - kind: unit ref: "modules/cabana/form_schema_test.go#TestFormSchemaRejects" status: pass human_judgment: false - id: D5 description: "Sanitizer edge behaviour (dropped script/svg/on*/style/id, javascript: and // URLs, 64 KiB, 2000-node and depth-32 caps) and the model-type guard have dedicated unit tests" verification: [] human_judgment: true rationale: "Checked here only with a throwaway probe; the named sanitizer, asset and action unit tests (TestPhase101*) are plan 10.1-04's scope per CLAUDE.md rule 3" - id: D6 description: "OpenAPI document and generated TypeScript types cover every new admin API route" requirement: ADMIN-07 verification: - kind: other ref: "scripts/check-admin-openapi.sh --check" status: pass - kind: unit ref: "modules/cabana/phase09_contract_test.go#TestPhase09ContractInventory" status: pass - kind: other ref: "npm --prefix admin run typecheck" status: pass human_judgment: false duration: 25min completed: 2026-09-28 status: complete --- # Phase 10.1 Plan 01: Runtime admin extension point (framework Go) Summary **cabana-owned widget, toolbar and partial routes backed by new pact contracts, an exact-allowlist plugin asset route under the admin prefix, and an html/template partial renderer whose output is sanitized with x/net/html into a capped JSON node tree, all typed in the admin OpenAPI document and proven on the acme conformance fixture** ## Performance - **Duration:** 25 min - **Started:** 2026-09-28T21:28:17Z - **Completed:** 2026-09-28T21:52:58Z - **Tasks:** 3 - **Files modified:** 31 in summercms.go, 2 in fonoteka.go ## Accomplishments - Six pact contracts (`AdminClientAssets`, `AdminAction`, `AdminActionInput`, `AdminActionResult`, `HasAdminActions`, `AdminPartialData`) that plans 02 and 03 build against. - `type: widget` fields with boot checks: the tag must start with the plugin's `{vendor}-{plugin}-` prefix and must not be a reserved name, the action must be registered, fill keys must be writable scalar fields of the same form, and the controller must declare JS. `POST .../widgets/{field}` loads the record through `FormExtendQuery` without a row lock and filters fill keys on the server. - Registered toolbar actions share one namespace with widget actions, with `create` and `delete` reserved. `toolbarActions` in the list schema is filtered by permission, and `POST .../toolbar/{action}` accepts only `{}`. - Plugin JS/CSS is read and sha256-hashed at boot and served by exact key with `boardwalk.SetSecurityHeaders`, `Cross-Origin-Resource-Policy: same-origin`, `no-cache` and an ETag. List and form schemas carry `assets` URLs with `?v=`. A lookup miss falls through to the SPA, so dist assets still load. - `type: partial` is supported and `headerPartial` is added. Templates are parsed at boot and cloned for each request. Output goes through x/net/html `ParseFragment` and a tag, attribute and URL allowlist, with caps of 64 KiB, 2000 nodes and depth 32. `GET .../partials/{name}` with an optional scoped `?id=` serves the result. - OpenAPI regenerated with the recursive `cabana.PartialNode`. Route inventories were updated in both repositories, and the acme conformance fixture exercises widget, toolbar, both partials and both assets end to end. ## Task Commits summercms.go: 1. **Task 1: widget action tracer** - `f928194` (feat) 2. **Task 2: controller assets and toolbar actions** - `8b1cb24` (feat) 3. **Task 3: header and form partials** - `771d2cc` (feat) fonoteka.go (route inventory, separate repository): 1. `dcb64c9` test(10.1-01): expect the framework widget action route 2. `75ab47f` test(10.1-01): expect the framework toolbar action route 3. `be3fbf4` test(10.1-01): expect the framework partial route and new partial error ## Files Created/Modified - `modules/pact/capabilities.go`: the six extension contracts - `modules/cabana/extension.go`: boot validation of actions, widgets, client assets and partials - `modules/cabana/actions.go`: widget and toolbar handlers, strict body decode, `readScopedRecord`, fill filter - `modules/cabana/plugin_assets.go`: exact-allowlist asset handler and schema URL builder - `modules/cabana/partial_render.go`: template parse and render, allowlist walk, caps, view-model guard, partial handler - `modules/cabana/form_schema.go`, `list_schema.go`, `settings.go`: widget, partial, `headerPartial` and toolbar YAML rules - `modules/cabana/schema_types.go`, `contracts.go`, `registry.go`, `messages.go`, `http.go`, `admin_openapi.go`: types, registry wiring, label checks, routes, annotations - `modules/boardwalk/boardwalk.go`: exported `ContentType`, `SetSecurityHeaders` - `admin/openapi/admin.json`, `admin/src/api/schema.d.ts`: regenerated - `admin/tests/fixtures/*.json`: `assets` and `toolbarActions` keys for the typed fixtures - READMEs of pact, cabana and boardwalk ## Decisions Made - An action label containing `::` is a phrase key and must resolve at boot. Any other label is literal text. - A toolbar action body must be exactly `{}`. A `record_id` or `values` key is a 422, so a toolbar action can never become a record lookup. - The partial view-model guard also refuses types that contain html/template's trusted content types (`template.HTML` and its siblings). This enforces the plan's "no raw HTML string marked safe" rule at the type level. The allowlist still backs it up for values stored behind `any`. - Any href or src containing whitespace or control characters is refused. Browsers strip those characters, so `//host` would otherwise become a protocol-relative URL. ## Deviations from Plan ### Auto-fixed Issues **1. [Rule 3 - Blocking] Renamed the new scalar helper to avoid a collision** - **Found during:** Task 1 - **Issue:** `scalarValue` already exists in `query.go` (it converts a filter jsonScalar), so the package did not compile. - **Fix:** Named the new predicate `isJSONScalar`. - **Files modified:** modules/cabana/actions.go - **Committed in:** f928194 **2. [Rule 3 - Blocking] Updated the unsafe-route pins in TestPhase10CSRF and TestPhase10Coverage** - **Found during:** Tasks 1 and 2 - **Issue:** Both tests pin the exact set of mounted unsafe routes (9, besides login). The plan's inventory step named only `phase09Routes` and `phase09ProtectedCalls`. - **Fix:** Added each new POST to the pinned list and raised the count to 10, then 11. The CSRF walk still exercises every new route automatically. - **Files modified:** modules/cabana/phase10_csrf_test.go, modules/cabana/phase10_coverage_test.go - **Committed in:** f928194, 8b1cb24 **3. [Rule 3 - Blocking] Updated the exact-JSON list schema expectations** - **Found during:** Task 2 - **Issue:** `TestListSchemaCompile/Empty/Single/Filter` compare whole `ListSchema` JSON, and `toolbarActions` and `assets` are always emitted. - **Fix:** Inserted `"toolbarActions":[],"assets":{"scripts":[],"styles":[]}` after `toolbarButtons` in the expected strings. - **Files modified:** modules/cabana/list_schema_test.go - **Committed in:** 8b1cb24 **4. [Rule 3 - Blocking] Updated a fonoteka test that pinned the Phase 9 partial rejection text** - **Found during:** Task 3 (full fonoteka suite) - **Issue:** `TestCollectionsAdminRejectsPartial` expected "type partial is not supported", which the plan removes. - **Fix:** It now expects "type partial needs a path". A bare legacy `type: partial` still fails boot, for the new reason. - **Files modified:** ../fonoteka.go/plugins/golem15/fonoteka/admin_collections_test.go - **Committed in:** be3fbf4 (fonoteka.go) **5. [Rule 2 - Missing critical] Extra URL and view-model hardening** - **Found during:** Task 3 - **Issue:** The plan's URL rule (one leading `/`) is bypassable with tab or newline characters. Its model-type guard does not cover `template.HTML` fields. - **Fix:** `safePartialURL` refuses whitespace and control characters. `refusedViewModel` refuses trusted template content types. - **Files modified:** modules/cabana/partial_render.go - **Committed in:** 771d2cc --- **Total deviations:** 5 auto-fixed (4 blocking, 1 missing critical) **Impact on plan:** All were needed to keep pinned tests green or to close a security gap. No scope creep. ## Issues Encountered - `go.sum` did not change; `go mod tidy` only moved `golang.org/x/net` to the direct block. `go.sum` is listed in the plan's files but needed no edit. - Task 1 and Task 2 ran the plan's targeted fonoteka tests. The full fonoteka suite ran at Task 3 and surfaced deviation 4, which was fixed in the same task. ## Known Stubs None. The fixture actions are test doubles by design, and the Discogs stubs belong to plan 10.1-03. ## User Setup Required None. No external service configuration required. ## Next Phase Readiness - Plan 10.1-02 (SPA) can consume `assets`, `toolbarActions`, `headerPartial`, the widget field keys, and the `PartialNode`/`PartialView`/`AdminActionResult` schema aliases from `schema.d.ts`. - Plan 10.1-03 (Albums) implements `AdminClientAssets`, `HasAdminActions` and `AdminPartialData` on the albums controller. - Plan 10.1-04 owns the named unit tests (TestPhase101FormExtensionSchema, Actions, Toolbar, Assets, PartialSanitizer) and `scripts/check-phase10.1.sh`. - The README partial example uses the `summer-stats` class names. The admin SPA only styles them after plan 10.1-02. ## Self-Check: PASSED - FOUND: modules/cabana/extension.go, actions.go, plugin_assets.go, partial_render.go, modules/pact/capabilities.go, admin/openapi/admin.json - FOUND commits: f928194, 8b1cb24, 771d2cc (summercms.go); dcb64c9, 75ab47f, be3fbf4 (fonoteka.go) - Plan verification: `go vet ./... && go test ./...` (summercms.go), `go vet` and `go test ./plugins/golem15/fonoteka/...` (fonoteka.go), `scripts/check-admin-openapi.sh --check`, `npm --prefix admin run typecheck`, `scripts/check-phase10.sh --hygiene`: all pass. --- *Phase: 10.1-runtime-admin-extension-point* *Completed: 2026-09-28*