Files
summercms/.planning/phases/10.1-runtime-admin-extension-point/10.1-01-SUMMARY.md

283 lines
16 KiB
Markdown

---
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 /<tab>/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 `/<tab>/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*