docs(10.1): create phase plans for the runtime admin extension point

Four plans: framework Go contracts and routes, framework SPA hosts,
Albums proof in fonoteka.go, and unit tests with the phase gate.
Adds ADMIN-07 to REQUIREMENTS.md and fills the Phase 10.1 roadmap goal,
success criteria and plan list.
This commit is contained in:
Jakub Zych
2026-09-28 22:25:50 +02:00
parent 04c587a5a3
commit ccdc014078
6 changed files with 1241 additions and 7 deletions

View File

@@ -0,0 +1,331 @@
---
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); 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 (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>

View File

@@ -0,0 +1,329 @@
---
phase: 10.1-runtime-admin-extension-point
plan: 02
type: execute
wave: 2
depends_on: [10.1-01]
files_modified:
- admin/src/app/pluginAssets.ts
- admin/src/components/form/formContext.ts
- admin/src/components/form/fields/WidgetField.vue
- admin/src/components/form/fields/PartialField.vue
- admin/src/components/partial/PartialHost.vue
- admin/src/components/partial/partialNodes.ts
- admin/src/components/form/registry.ts
- admin/src/components/form/FormField.vue
- admin/src/components/list/ListToolbar.vue
- admin/src/views/FormView.vue
- admin/src/views/ListView.vue
- admin/src/api/types.ts
- admin/src/styles/main.css
- admin/vite.config.ts
- admin/tests/fixtures/extension.form-schema.json
- admin/tests/fixtures/extension.list-schema.json
- admin/tests/fixtures/extension.partial.json
- admin/tests/fixtures/typed.ts
- admin/tests/smoke/extension.smoke.test.ts
- modules/phrasebook/backend/lang/en/lang.yaml
- modules/phrasebook/backend/lang/pl/lang.yaml
- modules/cabana/README.md
- modules/boardwalk/dist/**
autonomous: true
requirements: [ADMIN-07]
estimate:
tokens: 120000
raw_tokens: 120000
tasks: 3
confidence: low
must_haves:
truths:
- "Per D-04, D-05 and D-08, a `type: widget` field loads its controller's scripts, waits for customElements.whenDefined with a 5000 ms timeout, creates the element imperatively and sets attributes only (record-id, empty on create; field-name; locale; fill-values as JSON of the current fill values kept in sync; label from the action label; busy-label); the element receives no token, cookie, Vue instance or function."
- "Per D-05 and D-07, a bubbling `summer-action` event from the element makes the SPA POST {record_id, values} to .../widgets/{field} through the typed API client with the admin cookie and X-Requested-With; repeat events are ignored while `busy` is set; on success only keys in field.fill that are present in the response fill are patched onto the form, the form becomes dirty, those fields' errors clear, nothing is saved, and result.message is a success toast; on failure a danger toast shows the server message or backend::lang.extension.action_failed and the element gets state=\"error\"."
- "Per D-09, widget and partial fields are registered renderers that are never part of the save body and render on create and update; relation-manager keeps its record-only rule."
- "Per D-17, PartialHost fetches .../partials/{name} (with ?id= on an existing record for form partials) and builds the node tree with Vue h() under the same tag, attribute and URL allowlist as the server; unknown tags are unwrapped, unknown or event attributes are dropped, text stays text, and admin/src contains no raw-HTML sink."
- "Per D-03 and D-11, when the list schema names a headerPartial the ListView renders it between the page header and the list card; it refetches after a successful bulk delete or custom toolbar action and not on search, filter, sort or page changes."
- "Per D-12, custom toolbar names present in schema.toolbarActions render in the ListToolbar after the built-ins in declared order as outline buttons labelled from toolbarActions, enabled regardless of the selection; a click disables the button with aria-busy, POSTs {} to .../toolbar/{action}, toasts result.message, reloads the list and refetches the header partial; a failure is a danger toast; a name the admin may not run is never rendered; create and delete behave as in Phase 10."
- "Per D-14 and D-16, plugin scripts and styles load only when their controller's list or form opens, only from URLs under {runtime.base}/assets/, once per URL (a failed script can retry on a later navigation), and stylesheet links of other controllers are disabled; the list table never waits for plugin JS."
- "UI consideration (loading S1): the first header-partial fetch shows one full-width 80px radius-16 bg-skel block (aria-hidden) with aria-busy on the host; refetches keep the previous nodes visible with aria-busy and no skeleton flash."
- "UI consideration (empty S1): a header partial with zero nodes renders nothing and takes no gap."
- "UI consideration (error S1): a header partial failure renders the full-width extension failure box with backend::lang.extension.partial_failed and the list stays usable."
- "UI consideration (overflow S1 stats items): the .summer-stats kit wraps items with flex-wrap, a 32px column gap and an 8px row gap inside one card and never scrolls horizontally."
- "UI consideration (overflow S1/S2 partial output): a server 500 for an oversized partial shows the partial_failed box, never a truncated render."
- "UI consideration (loading S2): a form partial shows a 44px radius-10 bg-skel bar until its nodes arrive."
- "UI consideration (error S2): a form partial failure shows the extension failure box with partial_failed in its row and the form stays saveable."
- "UI consideration (empty S2): a form partial with zero nodes shows only its label, if declared, and no placeholder text; with no label there is no label row."
- "UI consideration (partial S2/S3 on create): form partials and widgets render on create; the partial is fetched without ?id= and the widget gets record-id=\"\"."
- "UI consideration (loading S3): a widget shows a 42px by 160px radius-10 bg-skel bar with aria-busy on its group until whenDefined resolves or 5000 ms pass."
- "UI consideration (error S3 load): on a script error or timeout the element is not mounted and the widget_failed failure box is shown."
- "UI consideration (error S3 POST): a failed action POST shows a danger toast (server message or action_failed), sets state=\"error\" and leaves the form values untouched."
- "UI consideration (loading S3 in flight): while the POST runs the element carries the busy attribute and repeat summer-action events are ignored."
- "UI consideration (form S3 fill write-back): only field.fill keys present in result.fill are patched; the form becomes dirty, their errors clear, and nothing saves until Save."
- "UI consideration (empty S3 with no fill values): the element mounts with fill-values `{}` and record-id \"\" and the framework never hides or disables it."
- "UI consideration (loading/error S4): a custom toolbar button is disabled with aria-busy during its POST; success toasts then reloads the list and refetches the partial; failure toasts danger."
- "UI consideration (long-text S2): .summer-partial sets overflow-wrap: anywhere so long words and URLs wrap inside the row."
- "UI consideration (long-text/overflow S5): the extension failure box text wraps and the box grows past min-h-input, switching to items-start with 10px vertical padding when multi-line, so the pl widget_failed copy is fully readable."
- statement: "UI consideration (error S6, CSS bleed across controllers): stylesheet links of inactive controllers are disabled; the pluginAssets toggle is covered here and in 10.1-04, and a real-browser check (RESEARCH A4) confirms module scripts load under CSP script-src 'self'."
verification: backstop
artifacts:
- path: "admin/src/app/pluginAssets.ts"
provides: "Idempotent per-controller script and stylesheet loader with the same-origin prefix check"
exports: ["loadScript", "loadStyles", "activateStyles", "loadControllerAssets"]
- path: "admin/src/components/form/fields/WidgetField.vue"
provides: "Custom-element host bridging summer-action to the typed POST, fill patch and toast"
- path: "admin/src/components/partial/PartialHost.vue"
provides: "Header and form partial host with loading, empty and error states"
- path: "admin/src/components/partial/partialNodes.ts"
provides: "Client allowlist and h() renderer for PartialNode trees"
- path: "admin/src/components/form/formContext.ts"
provides: "InjectionKeys for form values, patch and locale"
- path: "modules/boardwalk/dist/index.html"
provides: "Rebuilt embedded SPA containing the new hosts"
key_links:
- from: "admin/src/components/form/fields/WidgetField.vue"
to: "POST /{vendor}/{plugin}/{controller}/widgets/{field}"
via: "typed openapi-fetch api.POST"
pattern: "widgets/\\{field\\}"
- from: "admin/src/components/partial/PartialHost.vue"
to: "GET /{vendor}/{plugin}/{controller}/partials/{name}"
via: "typed openapi-fetch api.GET"
pattern: "partials/\\{name\\}"
- from: "admin/src/views/ListView.vue"
to: "POST /{vendor}/{plugin}/{controller}/toolbar/{action}"
via: "onAction handler"
pattern: "toolbar/\\{action\\}"
- from: "admin/src/components/form/registry.ts"
to: "WidgetField.vue and PartialField.vue"
via: "widget and partial renderer registration plus the valueless set"
pattern: "'(widget|partial)'"
prohibitions:
- "No raw-HTML sink and no HTML-string parser anywhere in admin/src, including comments (the Phase 10 hygiene list)."
- "No direct network call outside admin/src/api/client.ts; plugin assets load only through script and link elements."
- "No npm package is added or re-pinned."
---
## 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 SPA half of the extension point in summercms.go/admin: the plugin asset loader, the widget host, the partial host for form and list-header partials, custom toolbar buttons, the partial style kit and the new framework strings, then rebuild and commit the embedded dist.
Purpose: This is the only Node-built part of the phase; after it, application plugins extend the admin with Go, YAML, templates and plain JS alone (D-04). Decisions implemented: D-04, D-05, D-07 (client patch), D-08, D-09, D-12 (SPA), D-14, D-16 (loader prefix check), D-17 (client half); UI-SPEC surfaces S1 to S6.
Output: new SPA modules and components, updated form and list views, the `backend::lang.extension.*` strings, smoke tests importing every new module, rebuilt `modules/boardwalk/dist`.
Repo: summercms.go only. Code and planning docs 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/STATE.md
@.planning/phases/10.1-runtime-admin-extension-point/10.1-CONTEXT.md
@.planning/phases/10.1-runtime-admin-extension-point/10.1-UI-SPEC.md
@.planning/phases/10.1-runtime-admin-extension-point/10.1-RESEARCH.md
@.planning/phases/10.1-runtime-admin-extension-point/10.1-01-SUMMARY.md
@.planning/phases/10-admin-vue-spa/design/README.md
@admin/src/api/schema.d.ts
@admin/src/components/form/registry.ts
@admin/src/views/FormView.vue
@admin/src/views/ListView.vue
<interfaces>
From 10.1-01 (typed in admin/src/api/schema.d.ts after its regeneration):
- `POST /{vendor}/{plugin}/{controller}/widgets/{field}` body `cabana.AdminActionRequest` {record_id?: number; values?: Record<string, unknown>} → `cabana.Envelope-cabana_AdminActionResult` {data: {message: string; fill: Record<string, unknown>}}; 403, 404, 422 are `cabana.ErrorEnvelope`.
- `POST /{vendor}/{plugin}/{controller}/toolbar/{action}` body `{}` → same envelope, fill always `{}`.
- `GET /{vendor}/{plugin}/{controller}/partials/{name}` query `id?` → `cabana.Envelope-cabana_PartialView` {data: {nodes: cabana.PartialNode[]}}; PartialNode {tag?: string; attrs?: Record<string, string>; text?: string; children?: PartialNode[]}.
- `cabana.FormField` gains widget?, action?, actionLabel? (localized), fill?, path?; `cabana.FormView.assets` and `cabana.ListSchema.assets` are `cabana.ControllerAssets` {scripts: string[]; styles: string[]} with absolute URLs `{base}/assets/...?v=`; `cabana.ListSchema.headerPartial?`; `cabana.ListSchema.toolbarActions` is `cabana.ToolbarAction[]` {name; label} already filtered to what the admin may run.
Existing SPA seams: `admin/src/components/form/control.ts` FieldControlProps {field, modelValue, controlId, invalid?, describedBy?, labels?, source?: ControllerParams | null, recordId?: number | null}; `registry.ts` renderers map, `isRegistered`, `needsRecord`, `ownsLabel` (field components import ../control, never ../registry); `formState.ts` editablePayload skips `!isRegistered(type)`; `app/runtime.ts` `runtime.base`; `app/i18n.ts` `t`, `message`, `currentLocale`; `state/useToasts.ts` `showToast(text, tone)`; `api/client.ts` `api` (the only allowed network call site); `components/ui/Button.vue` variants primary/outline/ghost/danger, size md/sm; `components/form/fields/UnsupportedField.vue` failure-box class string; tests use `tests/helpers.ts` (`mountApp`, `requestsTo`, API base `/admin-test/api/v1`) and typed fixtures in `tests/fixtures/typed.ts`.
Hygiene rules that must stay green (`scripts/check-phase10.sh --hygiene`): no raw-HTML directive words in admin/src even in comments; no direct network call outside api/client.ts; admin/src/api holds only schema.d.ts, client.ts and types.ts, and types.ts only aliases `Schemas['cabana.X']`; every .ts/.vue file in admin/src is imported by some test; no application names in admin/src, admin/tests or modules/cabana.
</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. Truths come from CONTEXT D-01..D-17 and UI-SPEC surfaces S1-S6; every resolved UI-SPEC "UI Considerations" row assigned to the SPA is a truth above (the S1 host "partial / zero-one-many" row was dismissed in the UI-SPEC and needs no must-have; Albums-specific rows live in 10.1-03).
- Discretion resolved: the widget also receives `label` and `busy-label` attributes (RESEARCH Open Question 4; additive, no credential) so plugin JS carries no strings; widgets render on create and update; the partial client allowlist lives in `partialNodes.ts` so 10.1-04 can unit-test it directly.
- Tests here are smoke tests (CLAUDE.md rule 3); branch-level Vitest suites are 10.1-04.
## Artifacts this phase produces
- `admin/src/app/pluginAssets.ts`: `loadScript(url)`, `loadStyles(controllerId, urls)`, `activateStyles(controllerId)`, `loadControllerAssets(controllerId, assets)`
- `admin/src/components/form/formContext.ts`: `FORM_VALUES`, `FORM_PATCH`, `FORM_LOCALE` InjectionKeys
- `admin/src/components/form/fields/WidgetField.vue`, `admin/src/components/form/fields/PartialField.vue`
- `admin/src/components/partial/PartialHost.vue` (props `source`, `name`, `recordId`, `variant: 'header' | 'field'`, `reloadKey`)
- `admin/src/components/partial/partialNodes.ts`: `PARTIAL_TAGS`, `partialAttrAllowed(tag, name, value)`, `renderPartialNodes(nodes)`
- registry.ts: `widget` and `partial` renderers, `valueless` set, exported `groupLabelled(type)`
- ListToolbar props `actions: ToolbarAction[]`, `busyAction: string | null`, event `action: [name]`
- types.ts aliases `AdminActionRequest`, `AdminActionResult`, `ControllerAssets`, `ToolbarAction`, `PartialNode`, `PartialView`
- CSS kit classes `.summer-partial`, `.summer-stats`, `.summer-stat`, `.summer-stat__label`, `.summer-stat__value`
- Phrase keys `backend::lang.extension.busy`, `.widget_failed`, `.partial_failed`, `.action_failed` (en, pl)
- Custom-element contract: event name `summer-action`; attributes `record-id`, `field-name`, `locale`, `fill-values`, `label`, `busy-label`, `busy`, `state`
- Vite dev proxy entry `${devPrefix}/assets`
- Fixtures `tests/fixtures/extension.form-schema.json`, `extension.list-schema.json`, `extension.partial.json`; smoke test `tests/smoke/extension.smoke.test.ts`
<tasks>
<task type="tracer">
<name>Task 1: An admin clicks a plugin widget on a form, and only its fill fields change before a toast confirms</name>
<reversibility rating="costly">D-05 and D-08 fix the element contract (attributes only, the summer-action event, the SPA owning HTTP) that every plugin widget is written against; user-locked, so flagged without a checkpoint.</reversibility>
<precondition>10.1-01 is executed: `grep -q 'widgets/{field}' admin/src/api/schema.d.ts &amp;&amp; grep -q 'cabana.ControllerAssets' admin/src/api/schema.d.ts` succeeds.</precondition>
<files>admin/src/app/pluginAssets.ts, admin/src/components/form/formContext.ts, admin/src/components/form/fields/WidgetField.vue, admin/src/components/form/registry.ts, admin/src/components/form/FormField.vue, admin/src/views/FormView.vue, admin/src/api/types.ts, modules/phrasebook/backend/lang/en/lang.yaml, modules/phrasebook/backend/lang/pl/lang.yaml, admin/tests/fixtures/extension.form-schema.json, admin/tests/fixtures/typed.ts, admin/tests/smoke/extension.smoke.test.ts, modules/boardwalk/dist/**</files>
<read_first>admin/src/components/form/registry.ts, admin/src/components/form/control.ts, admin/src/components/form/FieldRenderer.vue, admin/src/components/form/FormField.vue, admin/src/components/form/FormGrid.vue, admin/src/components/form/formState.ts, admin/src/components/form/fields/UnsupportedField.vue, admin/src/views/FormView.vue, admin/src/views/ListView.vue (onDelete POST-toast idiom), admin/src/app/runtime.ts, admin/src/app/i18n.ts, admin/src/api/types.ts, admin/src/api/schema.d.ts, admin/tests/helpers.ts, admin/tests/fixtures/typed.ts, admin/tests/smoke/form.smoke.test.ts, modules/phrasebook/backend/lang/en/lang.yaml, modules/phrasebook/phase10_test.go (TestPhase10SPAKeysResolve), .planning/phases/10.1-runtime-admin-extension-point/10.1-UI-SPEC.md (S3, S5, Copywriting Contract)</read_first>
<action>(1) types.ts: add aliases `AdminActionRequest`, `AdminActionResult` and `ControllerAssets` onto `Schemas['cabana.X']` only (the hygiene regex accepts nothing else).
(2) New admin/src/app/pluginAssets.ts (D-14, D-16): a module-level `Map<string, Promise<void>>` of scripts. `loadScript(url)` rejects any URL that does not start with `${runtime.base}/assets/` (T-10.1-13), otherwise appends one `<script type="module">` with that src to document.head, resolves on `load`, and on `error` deletes the map entry and rejects, so a later navigation can retry; a URL already in the map returns the same promise. `loadStyles(controllerId, urls)` creates one `<link rel="stylesheet" data-summer-controller="{controllerId}">` per new URL (same prefix check). `activateStyles(controllerId)` sets `disabled` on every plugin link owned by another controller and clears it on this controller's links. `loadControllerAssets(controllerId, assets)` runs activateStyles and loadStyles, then resolves when every script promise settles. Plugin files load only through script and link elements; no other network call.
(3) New admin/src/components/form/formContext.ts with typed InjectionKeys `FORM_VALUES` (read-only Ref of AdminRecord), `FORM_PATCH` ((name: string, value: unknown) => void) and `FORM_LOCALE` (Ref of string). FormView provides the values read-only, `patch` as its existing `update(name, value)` (so the form turns dirty and that field's errors clear) and the locale from `schema.meta.locale`; when the schema arrives it starts `loadControllerAssets(controllerId, schema.assets)` without awaiting it before rendering.
(4) registry.ts: register `widget` → WidgetField; add `const valueless = new Set([RELATION_MANAGER, 'widget'])` and make isRegistered use it, so editablePayload never sends a widget, while needsRecord stays relation-manager only (widgets render on create, UI-SPEC S3); export `groupLabelled(type)`, true for widget. FormField.vue: for a groupLabelled type render the visible label as `<span id="{base}-label" class="font-semibold">` (same asterisk rule) instead of `<label for>`.
(5) New admin/src/components/form/fields/WidgetField.vue (imports ../control, never ../registry; D-04, D-05, D-07, D-08): a host `<div :id="controlId" role="group" :aria-labelledby="`${controlId}-label`" :aria-describedby class="flex min-h-input items-center">` containing a `ref` div that Vue never renders children into. While loading, show a 42px by 160px radius-10 `bg-skel` bar and set `aria-busy="true"` on the group. Wait for `loadControllerAssets` of the form (idempotent) and `Promise.race([customElements.whenDefined(field.widget), a 5000 ms timeout])`; then create the element with `document.createElement(field.widget)` inside try/catch and append it to the ref div. Set attributes only: `record-id` (the record id, or "" on create), `field-name`, `locale` (FORM_LOCALE, falling back to currentLocale), `fill-values` (JSON of the current values of field.fill, kept current with a watch on FORM_VALUES; `{}` when empty), `label` (field.actionLabel, falling back to field.label) and `busy-label` (t('backend::lang.extension.busy')). Listen for `summer-action`: ignore it while busy; set the `busy` attribute and remove `state`; POST `/{vendor}/{plugin}/{controller}/widgets/{field}` through `api` with path `{...source, field: field.name}` and body `{record_id: recordId ?? undefined, values}`; on success call FORM_PATCH for each key of field.fill present in the response fill (never any other key, T-10.1-17) and `showToast(result.data.data.message)`; on failure `showToast(result.error?.error.message || t('backend::lang.extension.action_failed'), 'danger')` and set `state="error"`; always remove `busy`. On a load failure or timeout, mount nothing and render the S5 box: UnsupportedField's class string with `role="alert"`, CircleAlert 16px `text-danger` aria-hidden and t('backend::lang.extension.widget_failed'); when the text wraps the box switches to items-start with 10px vertical padding. Remove the listener on unmount. Never hand the element a token, cookie, Vue instance or function (T-10.1-18).
(6) Add the `extension:` group to modules/phrasebook/backend/lang/en/lang.yaml and pl/lang.yaml with keys busy, widget_failed, partial_failed and action_failed, using the exact en and pl strings from the UI-SPEC Copywriting Contract (TestPhase10SPAKeysResolve then proves every key the SPA uses resolves).
(7) Smoke: `admin/tests/fixtures/extension.form-schema.json` is an acme.demo.widgets form whose `lookup` field has widget `acme-demo-lookup`, action `lookup`, actionLabel "Look up" and fill [name], with assets.scripts holding one `/admin-test/assets/acme/demo/js/lookup.js?v=abc` URL; type it in typed.ts against `cabana.Envelope-cabana_FormView`. `admin/tests/smoke/extension.smoke.test.ts` defines an `acme-demo-lookup` element in the test before mount, stubs the script load, mounts the form route and proves: the attributes above are set; a summer-action POSTs `{record_id, values}` with X-Requested-With; a response fill containing `name` and a non-fill `color` patches only `name` and shows the toast; a second event while busy sends nothing; the save body omits `lookup`; the widget renders on create with record-id "". The test imports pluginAssets, formContext and WidgetField directly (hygiene rule: every src module imported by a test).
(8) Run `npm --prefix admin run build` and commit modules/boardwalk/dist with the code.</action>
<verify>
<automated>npm --prefix admin run typecheck &amp;&amp; npm --prefix admin test -- tests/smoke &amp;&amp; go test ./modules/phrasebook -run '^TestPhase10SPAKeysResolve$' -count=1 -v &amp;&amp; scripts/check-admin-dist.sh &amp;&amp; scripts/check-phase10.sh --hygiene</automated>
<fails_when>Any command exits non-zero; vitest prints "No test files found" or a "FAIL" line; the go test output lacks "--- PASS: TestPhase10SPAKeysResolve" or prints "no tests to run"; check-admin-dist.sh prints a diff; check-phase10.sh prints a line starting with "refuse:".</fails_when>
</verify>
<acceptance_criteria>
- `grep -c "\['widget', WidgetField\]" admin/src/components/form/registry.ts` prints 1.
- `grep -c "runtime.base" admin/src/app/pluginAssets.ts` prints at least 1.
- `grep -c 'widgets/{field}' admin/src/components/form/fields/WidgetField.vue` prints at least 1 and `grep -c 'customElements.whenDefined' admin/src/components/form/fields/WidgetField.vue` prints at least 1.
- `grep -c 'extension:' modules/phrasebook/backend/lang/en/lang.yaml` and `grep -c 'extension:' modules/phrasebook/backend/lang/pl/lang.yaml` each print 1.
- The extension smoke test covers every behaviour listed in item (7) and passes.
</acceptance_criteria>
<done>On a form with a widget field an admin sees the plugin element, clicks it, and gets only the declared fill fields updated (unsaved) with a toast, through the SPA's own authenticated POST.</done>
</task>
<task type="auto">
<name>Task 2: Header partials and form partials render as native admin markup through an allowlisted node renderer</name>
<files>admin/src/components/partial/PartialHost.vue, admin/src/components/partial/partialNodes.ts, admin/src/components/form/fields/PartialField.vue, admin/src/components/form/registry.ts, admin/src/views/ListView.vue, admin/src/styles/main.css, admin/src/api/types.ts, admin/tests/fixtures/extension.list-schema.json, admin/tests/fixtures/extension.partial.json, admin/tests/fixtures/typed.ts, admin/tests/smoke/extension.smoke.test.ts, modules/cabana/README.md, modules/boardwalk/dist/**</files>
<read_first>admin/src/components/form/registry.ts and admin/src/components/form/FormField.vue (Task 1), admin/src/views/ListView.vue, admin/src/styles/main.css (tokens and the components layer), admin/src/components/form/control.ts (allowedAttributes idiom), admin/src/api/schema.d.ts (cabana.PartialNode, cabana.PartialView), modules/cabana/partial_render.go (10.1-01 allowlist to mirror), modules/cabana/README.md, admin/tests/smoke/extension.smoke.test.ts (Task 1), .planning/phases/10.1-runtime-admin-extension-point/10.1-UI-SPEC.md (Partial style kit, S1, S2, S5, Public CSS variables)</read_first>
<action>(1) types.ts: aliases `PartialNode` and `PartialView` onto `Schemas['cabana.X']`.
(2) New admin/src/components/partial/partialNodes.ts (D-17, T-10.1-14): `PARTIAL_TAGS` (the same tag list as modules/cabana/partial_render.go), `partialAttrAllowed(tag, name, value)` (global class, title, lang, dir, role, aria-* and data-*; a[href] only when it starts with exactly one "/" or with "#"; img[src] only a same-origin "/" path, plus alt, width, height; td/th colspan, rowspan, scope; time datetime; data value; meter value, min, max, low, high, optimum; progress value, max; never id, style or on*) and `renderPartialNodes(nodes)` building VNodes with Vue `h()`: an allowed tag becomes `h(tag, allowedAttrs, children)`, any other tag contributes only its children, text nodes become plain text children, and recursion stops at depth 32. It never parses a string and never uses a raw-HTML sink (the Phase 10 hygiene list also matches comments, so keep those words out of comments).
(3) New admin/src/components/partial/PartialHost.vue with props `source` (ControllerParams), `name`, `recordId` (optional), `variant` ('header' | 'field') and `reloadKey` (number): GET `/{vendor}/{plugin}/{controller}/partials/{name}` through `api`, with query `id` only when recordId is set; the root element has class `summer-partial` and renders renderPartialNodes. States (UI-SPEC S1, S2): the first load shows, for header, one full-width 80px radius-16 `bg-skel` block (aria-hidden) and, for field, a 44px radius-10 `bg-skel` bar, with `aria-busy="true"` on the host; a reloadKey change refetches while keeping the previous nodes visible with aria-busy and no skeleton; zero nodes render nothing (the header variant then occupies no gap); an error renders the S5 box (UnsupportedField geometry, role=alert, CircleAlert, t('backend::lang.extension.partial_failed')) at full width for header. No live region.
(4) New admin/src/components/form/fields/PartialField.vue wrapping PartialHost variant `field` with `name` = field.path, `source` and `recordId` from the props (on create it fetches without id). registry.ts registers `partial` → PartialField and adds `partial` to the valueless set and to groupLabelled; FormField.vue renders no label row for a groupLabelled field without a label; the host is `role="group"` labelled by the label span when there is one.
(5) ListView.vue (D-03, D-11): when `schema.headerPartial` is set, render `<PartialHost variant="header">` between the page `<header>` and the list card; keep a `partialReload` counter passed as reloadKey and increment it after a successful bulk delete (after loadList in onDelete). Do not refetch on search, filter, sort or page changes.
(6) admin/src/styles/main.css `@layer components`: `.summer-partial` (14px/1.5, `color: var(--c-text)`, `overflow-wrap: anywhere`; p, ul and ol `margin: 0 0 8px` with the last child margin 0; links in `var(--c-text)`, underlined, with the 3px focus ring), `.summer-stats` (background var(--c-surface), 1px var(--c-border), radius 16px, box-shadow var(--c-shadow-card), padding 16px 20px, `display: flex; flex-wrap: wrap; column-gap: 32px; row-gap: 8px`, margin 0), `.summer-stat` (flex column-reverse, gap 4px, min-width 0), `.summer-stat__label` (13px/1.5, weight 400, var(--c-muted), margin 0, wraps) and `.summer-stat__value` (20px/1.2, weight 600, var(--c-text), `font-variant-numeric: tabular-nums`, margin 0), reading only existing `--c-*` variables.
(7) modules/cabana/README.md: a "Partial style kit and plugin CSS variables" subsection listing the kit classes with the recommended `<dl class="summer-stats">` markup and the public variables plugin CSS may read (`--c-bg`, `--c-surface`, `--c-subtle`, `--c-border`, `--c-border-strong`, `--c-text`, `--c-muted`, `--c-placeholder`, `--c-primary`, `--c-on-primary`, `--c-danger`, `--c-danger-soft`, `--c-hover`, `--c-sel`, `--c-skel`, `--c-ring`), stating that plugins must not hardcode hex colours or rely on Tailwind utilities.
(8) Smoke: `extension.list-schema.json` (acme.demo.widgets list with headerPartial `stats`, assets, toolbarActions []) and `extension.partial.json` (a PartialView whose nodes include a `dl.summer-stats` strip, a `script` element node, an `a` with an `onclick` attribute and a `javascript:` href, and a text node holding angle brackets), both typed in typed.ts. Extend extension.smoke.test.ts: the strip renders with its classes; the script node, onclick and javascript: href never reach the DOM; the angle-bracket text renders as text; zero nodes render nothing; a 500 shows the partial_failed box while the table still renders; a bulk delete refetches the partial with prior nodes kept; a form partial renders on create without id and on update with id. Import partialNodes, PartialHost and PartialField.
(9) Rebuild with `npm --prefix admin run build` and commit modules/boardwalk/dist.</action>
<verify>
<automated>npm --prefix admin run typecheck &amp;&amp; npm --prefix admin test -- tests/smoke &amp;&amp; go test ./modules/phrasebook -run '^TestPhase10SPAKeysResolve$' -count=1 -v &amp;&amp; scripts/check-admin-dist.sh &amp;&amp; scripts/check-phase10.sh --hygiene</automated>
<fails_when>Any command exits non-zero; vitest prints "No test files found" or a "FAIL" line; the go test output lacks "--- PASS: TestPhase10SPAKeysResolve"; check-admin-dist.sh prints a diff; check-phase10.sh prints a line starting with "refuse:".</fails_when>
</verify>
<acceptance_criteria>
- `grep -c "\['partial', PartialField\]" admin/src/components/form/registry.ts` prints 1.
- `grep -c 'partials/{name}' admin/src/components/partial/PartialHost.vue` prints at least 1 and `grep -c 'headerPartial' admin/src/views/ListView.vue` prints at least 1.
- `grep -c '\.summer-stats' admin/src/styles/main.css` prints at least 1 and `grep -c 'overflow-wrap: anywhere' admin/src/styles/main.css` prints at least 1.
- `grep -c 'summer-stats' modules/cabana/README.md` prints at least 1.
- `scripts/check-phase10.sh --hygiene` exits 0 (no raw-HTML sink, every new module imported by a test).
</acceptance_criteria>
<done>A header partial above a list and a form partial in a row render the server's node tree as native, themed markup with loading, empty and error states, and hostile nodes never reach the DOM.</done>
</task>
<task type="auto">
<name>Task 3: Custom toolbar actions run from the list, and plugin CSS applies only to its own controller</name>
<reversibility rating="costly">D-12's rendering rule (declared order after the built-ins, server-filtered names only, no disabled placeholders) is what every registered toolbar action relies on; user-locked, flagged without a checkpoint.</reversibility>
<files>admin/src/components/list/ListToolbar.vue, admin/src/views/ListView.vue, admin/src/app/pluginAssets.ts, admin/src/api/types.ts, admin/vite.config.ts, admin/tests/fixtures/extension.list-schema.json, admin/tests/smoke/extension.smoke.test.ts, modules/boardwalk/dist/**</files>
<read_first>admin/src/components/list/ListToolbar.vue, admin/src/views/ListView.vue (buttons split, onDelete), admin/src/app/pluginAssets.ts (Task 1), admin/vite.config.ts, admin/tests/list/ListToolbar.test.ts, admin/tests/smoke/list.smoke.test.ts, .planning/phases/10.1-runtime-admin-extension-point/10.1-UI-SPEC.md (S4, S6), .planning/phases/10.1-runtime-admin-extension-point/10.1-RESEARCH.md (Pitfall 6)</read_first>
<action>(1) types.ts: alias `ToolbarAction` onto `Schemas['cabana.ToolbarAction']`.
(2) ListView.vue (D-12): `toolbarButtons` keeps the declared order minus `create`, holding `delete` and every name present in `schema.toolbarActions` (the server already filtered them by permission; a name missing there is not rendered, never shown disabled). Pass `actions` (schema.toolbarActions) and `busyAction` to ListToolbar. `onAction(name)`: ignore when busyAction is set; set busyAction; POST `/{vendor}/{plugin}/{controller}/toolbar/{action}` through `api` with body `{}`; on success `showToast(result.data.data.message)`, then `await loadList()` and increment partialReload; on failure `showToast(result.error?.error.message || t('backend::lang.extension.action_failed'), 'danger')`; finally clear busyAction. No confirmation dialog. When the list schema arrives start `loadControllerAssets(controllerId, schema.assets)` (D-14); the table renders without waiting for it.
(3) ListToolbar.vue: props gain `actions: ToolbarAction[]` and `busyAction: string | null`; emits gain `action: [name: string]`. In the existing button loop, a name other than `delete` that has an entry in actions renders `<Button variant="outline" size="md" :data-action="name">` with the entry's label as text (no icon), `disabled` and `aria-busy="true"` while busyAction equals it, independent of the selection count, emitting `action`. Delete keeps its Phase 10 behaviour.
(4) pluginAssets.ts: make sure activateStyles runs on every list and form mount (both views call loadControllerAssets), so the stylesheets of other controllers are disabled (UI-SPEC S6, T-10.1-16).
(5) admin/vite.config.ts: add a `${devPrefix}/assets` proxy entry to devTarget with `changeOrigin: false` beside the existing `/api` entry, so plugin assets load under `npm run dev` (Pitfall 6).
(6) Smoke: extension.list-schema.json gains buttons `[create, delete, recount]` with toolbarActions `[{name: recount, label: Recount}]`, plus a second list fixture variant where toolbarActions is empty. Extend extension.smoke.test.ts: the Recount button renders after Delete, is enabled with no selection, POSTs `{}` to `.../toolbar/recount` with X-Requested-With, toasts the message, reloads the list and refetches the header partial; with empty toolbarActions no Recount button renders; a 500 shows a danger toast; opening a second controller disables the first controller's stylesheet link.
(7) Rebuild with `npm --prefix admin run build` and commit modules/boardwalk/dist.</action>
<verify>
<automated>npm --prefix admin run typecheck &amp;&amp; npm --prefix admin test &amp;&amp; scripts/check-admin-dist.sh &amp;&amp; scripts/check-phase10.sh --hygiene &amp;&amp; go test ./modules/phrasebook ./modules/boardwalk -count=1</automated>
<fails_when>Any command exits non-zero; vitest prints "No test files found", a "FAIL" line or "Unhandled Rejection"; check-admin-dist.sh prints a diff; check-phase10.sh prints a line starting with "refuse:".</fails_when>
<human-check>
<test>Run the Vite dev server against a running `summer serve` (SUMMER_ADMIN_DEV_TARGET) and open a list whose controller declares assets; open the browser network tab.</test>
<expected>The plugin script and stylesheet load through the /assets proxy with 200 and a JavaScript/CSS content type; switching to another controller disables the first stylesheet link.</expected>
<why_human>The Vite dev proxy and real module loading run only in a browser; happy-dom does not load module scripts.</why_human>
</human-check>
</verify>
<acceptance_criteria>
- `grep -c 'toolbar/{action}' admin/src/views/ListView.vue` prints at least 1.
- `grep -c 'aria-busy' admin/src/components/list/ListToolbar.vue` prints at least 1.
- `grep -c '/assets' admin/vite.config.ts` prints at least 1.
- The full Vitest suite passes, including the Phase 10 ListToolbar and ListView tests unchanged in behaviour for create and delete.
</acceptance_criteria>
<done>A registered toolbar action appears in the list toolbar for admins who may run it, runs with busy state, toast, list reload and partial refetch, and plugin stylesheets stay scoped to their controller.</done>
</task>
</tasks>
## Source coverage (this plan)
| Source | Item | Task |
|--------|------|------|
| CONTEXT | D-04 plain JS custom elements, no Vue in plugins | 1 |
| CONTEXT | D-05 SPA owns HTTP (widget POST) | 1 |
| CONTEXT | D-07 patch only fill keys (client) | 1 |
| CONTEXT | D-08 element attributes, no token or cookie | 1 |
| CONTEXT | D-09 widget and partial registered, valueless | 1, 2 |
| CONTEXT | D-03 / D-11 list-header slot above the table | 2 |
| CONTEXT | D-12 custom toolbar buttons, POST, toast | 3 |
| CONTEXT | D-14 assets load per controller | 1, 3 |
| CONTEXT | D-16 same-origin prefix check in the loader | 1 |
| CONTEXT | D-17 client half: h() renderer, no raw sink | 2 |
| UI-SPEC | S1-S6, Copywriting Contract framework keys, partial style kit, public CSS variables | 1-3 |
| RESEARCH | Pattern 6, Pitfalls 4, 6, 7, 8 | 1-3 |
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Server schema → SPA loader | Asset URLs from the schema decide which scripts run in the admin origin |
| Server node tree → DOM | Partial content from plugin templates becomes DOM nodes |
| SPA → plugin custom element | Same-origin plugin code receives data from the SPA and signals it through events |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-10.1-13 | Tampering | pluginAssets loader (foreign script URL from a schema) | medium | mitigate | loadScript and loadStyles refuse any URL outside `${runtime.base}/assets/`; CSP `script-src 'self'` backs it up (Task 1). |
| T-10.1-14 | Tampering | PartialHost node rendering (XSS, client half) | high | mitigate | renderPartialNodes rebuilds only allowlisted tags and attributes with h(), keeps text as text, never parses strings; the Phase 10 hygiene stage refuses raw-HTML sinks in admin/src (Task 2). |
| T-10.1-16 | Tampering | plugin CSS bleeding across controllers | low | mitigate | activateStyles disables stylesheet links owned by other controllers on every list and form mount (Tasks 1, 3). |
| T-10.1-17 | Tampering | client-side fill write-back | medium | mitigate | WidgetField patches only keys in field.fill that the response returns; the save still goes through the server's writable projection (Task 1). |
| T-10.1-18 | Information Disclosure | plugin custom element receiving credentials | high | mitigate | The SPA sets attributes only (record id, field name, locale, fill snapshot, labels) and performs the POST itself; the session cookie stays HttpOnly and no token exists in JS (Phase 10 T-10-01) (Task 1). |
| T-10.1-SC | Tampering | npm dependencies | high | mitigate | No package added or re-pinned; the build uses the committed lockfile, and check-admin-dist.sh rebuilds from it. |
</threat_model>
<verification>
After Task 3: `npm --prefix admin run typecheck`, the full `npm --prefix admin test`, `scripts/check-admin-dist.sh`, `scripts/check-phase10.sh --hygiene` and `go test ./modules/phrasebook ./modules/boardwalk` pass; the committed dist contains the widget, partial and toolbar hosts.
</verification>
<success_criteria>
- Widgets mount plugin elements and bridge summer-action to the typed POST, patching only fill keys.
- Header and form partials render allowlisted node trees with the UI-SPEC loading, empty and error states.
- Custom toolbar actions run from the list with busy state, toast, reload and partial refetch.
- Plugin assets load per controller from the admin origin only; no npm change; dist rebuilt and drift-free.
</success_criteria>
<output>
Create `.planning/phases/10.1-runtime-admin-extension-point/10.1-02-SUMMARY.md` when done.
</output>

View File

@@ -0,0 +1,264 @@
---
phase: 10.1-runtime-admin-extension-point
plan: 03
type: execute
wave: 3
depends_on: [10.1-01, 10.1-02]
files_modified:
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/albums_admin_controller.go
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/albums/_stats.htm
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/albums/config_list.yaml
- ../fonoteka.go/plugins/golem15/fonoteka/models/album/fields.yaml
- ../fonoteka.go/plugins/golem15/fonoteka/assets/js/discogs-lookup.js
- ../fonoteka.go/plugins/golem15/fonoteka/assets/css/albums.css
- ../fonoteka.go/plugins/golem15/fonoteka/admin.go
- ../fonoteka.go/plugins/golem15/fonoteka/lang/en/lang.yaml
- ../fonoteka.go/plugins/golem15/fonoteka/lang/pl/lang.yaml
- ../fonoteka.go/plugins/golem15/fonoteka/admin_albums_test.go
- ../fonoteka.go/plugins/golem15/fonoteka/admin_phase10_controllers_test.go
- ../fonoteka.go/plugins/golem15/fonoteka/admin_phase10_copy_test.go
- ../fonoteka.go/plugins/golem15/fonoteka/admin_phase101_smoke_test.go
autonomous: true
requirements: [ADMIN-07]
estimate:
tokens: 80000
raw_tokens: 80000
tasks: 3
confidence: low
must_haves:
truths:
- "Per D-01, D-03 and D-11, the Albums list declares `headerPartial: stats` in config_list.yaml and GET /plytadmin/api/v1/golem15/fonoteka/albums/partials/stats returns a statistics strip for the admin's own collection: the total, one item per stored format with count above zero in getFormatOptions order, and a no-shelf item only when that count is above zero."
- "Per D-10, every stats count runs through albumsAdminController.scopeAlbums, and the view model is a curated struct of labels and integers, never an Album model; an admin bound to one collection never sees another collection's counts."
- "Per D-01, D-02, D-04, D-06 and D-07, the Albums form declares a `year` number field after `shelf` and a last, full-width `discogs` widget (widget golem15-fonoteka-discogs-lookup, action discogsLookup, fill [year, format]) that renders on create and update; its stub action answers the message key golem15.fonoteka::lang.discogs.stub_filled with fill {year: 1977, format: \"LP\"} and makes no outbound call; Phase 14 replaces the stub."
- "Per D-12, the Albums toolbar declares buttons [create, delete, discogsSync]; the discogsSync stub answers golem15.fonoteka::lang.discogs.stub_not_implemented and changes nothing; both Discogs actions require the existing Winter permission golem15.fonoteka.access_albums, so a limited admin gets 403."
- "Per D-13 to D-16, the controller declares assets/js/discogs-lookup.js and assets/css/albums.css through AdminJS/AdminCSS, both embedded in the plugin AdminFS and served under /plytadmin/assets/golem15/fonoteka/ with a JavaScript or CSS content type; the element is plain JS with no import, no user-facing string, no network request and no cookie or storage access."
- "UI consideration (populated S1 Albums stats strip): one .summer-stats card shows the total, then formats with count above zero in option order, then No shelf when above zero, each a 13px muted label under a 20px/600 value."
- "UI consideration (zero-one-many S1): with 0 albums the strip shows All albums 0 and no format items; counts are plain integers and labels are not pluralised, so 0, 1 and many share one layout."
- "UI consideration (overflow S1 stats items): the strip uses only the partial style kit classes, so items wrap inside the card with the kit's gaps and never scroll horizontally."
- statement: "UI consideration (long-text S1 stat labels): labels wrap without truncation inside min-width 0 items; a visual check with the longest pl labels at 768px width is required."
verification: backstop
- statement: "UI consideration (long-text S3 widget button and S4 toolbar button): labels are nowrap, the toolbar cluster wraps and the widget button grows past its 160px min-width; a visual check at 768px with the pl labels is required."
verification: backstop
- "go vet and the fonoteka plugin tests stay green after every task; the Phase 10 tests that pinned the Albums shape (field count, toolbarButtons, built-in field types) are updated in the same commit as the YAML change."
artifacts:
- path: "../fonoteka.go/plugins/golem15/fonoteka/controllers/albums/_stats.htm"
provides: "Albums statistics strip template using the partial style kit"
- path: "../fonoteka.go/plugins/golem15/fonoteka/assets/js/discogs-lookup.js"
provides: "golem15-fonoteka-discogs-lookup custom element"
- path: "../fonoteka.go/plugins/golem15/fonoteka/assets/css/albums.css"
provides: "Widget button styles reading only public --c-* variables"
- path: "../fonoteka.go/plugins/golem15/fonoteka/controllers/albums_admin_controller.go"
provides: "PartialData stats view model, AdminJS/AdminCSS, discogsLookup and discogsSync stub actions"
- path: "../fonoteka.go/plugins/golem15/fonoteka/admin_phase101_smoke_test.go"
provides: "TestPhase101AlbumsSmoke through the assembled router"
key_links:
- from: "../fonoteka.go/plugins/golem15/fonoteka/controllers/albums/config_list.yaml"
to: "../fonoteka.go/plugins/golem15/fonoteka/controllers/albums/_stats.htm"
via: "headerPartial: stats"
pattern: "headerPartial: stats"
- from: "../fonoteka.go/plugins/golem15/fonoteka/controllers/albums_admin_controller.go"
to: "scopeAlbums"
via: "every stats count query"
pattern: "scopeAlbums"
- from: "../fonoteka.go/plugins/golem15/fonoteka/models/album/fields.yaml"
to: "discogsLookup action"
via: "widget action key"
pattern: "action: discogsLookup"
- from: "../fonoteka.go/plugins/golem15/fonoteka/admin.go"
to: "assets/js/discogs-lookup.js"
via: "//go:embed list"
pattern: "assets/js/discogs-lookup.js"
prohibitions:
- "No real Discogs HTTP client, job or credential lookup (Phase 14)."
- "No new permission code; the Winter registerPermissions catalog stays as ported."
- "The plugin JS performs no network request and reads no cookie or browser storage."
---
## 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>
Prove the extension point on three real Albums surfaces in fonoteka.go: a statistics strip above the Albums list, a "Load from Discogs" widget on the Albums form whose stub fills Release year and Format, and a "Sync with Discogs" toolbar action whose stub toasts.
Purpose: D-01 requires the phase to land on Płytarium Albums, not only a toy fixture; D-02 keeps both Discogs actions as stubs that Phase 14 replaces. The work uses only Go, YAML, an html/template partial, plain JS and CSS; no Node step runs in this repository (D-04).
Output: Albums controller capabilities, YAML edits, template, JS and CSS assets, pl and en copy, updated Phase 10 tests and a smoke test.
Repo: fonoteka.go only (commits land in ../fonoteka.go). The framework it needs (10.1-01 routes, 10.1-02 SPA dist) is consumed through the go.work replace. 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/STATE.md
@.planning/phases/10.1-runtime-admin-extension-point/10.1-CONTEXT.md
@.planning/phases/10.1-runtime-admin-extension-point/10.1-UI-SPEC.md
@.planning/phases/10.1-runtime-admin-extension-point/10.1-RESEARCH.md
@.planning/phases/10.1-runtime-admin-extension-point/10.1-01-SUMMARY.md
@.planning/phases/10.1-runtime-admin-extension-point/10.1-02-SUMMARY.md
@../fonoteka.go/plugins/golem15/fonoteka/controllers/albums_admin_controller.go
@../fonoteka.go/plugins/golem15/fonoteka/admin.go
<interfaces>
From 10.1-01 (modules/pact): `AdminClientAssets{AdminJS() []string; AdminCSS() []string}`; `AdminAction{Name, Label string; Permissions []string; Run func(ctx context.Context, in AdminActionInput) (AdminActionResult, error)}`; `AdminActionInput{Field string; RecordID *uint64; Record any; Values map[string]any}`; `AdminActionResult{Message string; Fill map[string]any}`; `HasAdminActions{AdminActions() []AdminAction}`; `AdminPartialData{PartialData(ctx context.Context, name string, record any) (any, error)}`. Partial templates live at `{ConfigDir}/_{name}.htm`, get `.Data` as the view model and a `trans "key"` function; output passes through the node allowlist (dl/dt/dd/div/span with class are allowed; style attributes are dropped). Widget tags must start with `golem15-fonoteka-`; fill keys must be writable scalar form fields; asset paths must start with `assets/` and be embedded in AdminFS.
From fonoteka.go: `albumsAdminController{db func() *gorm.DB}` with `scopeAlbums(ctx, db) *gorm.DB` (collection scope, `1 = 0` when unbound), `DropdownOptions("format")` returning `models.Album{}.DropdownOptions("format")` (values LP, 2LP, CD, 2CD, MC, Box, EP 7" with `golem15.fonoteka::lang.album_format.*` labels); `models.Album` columns `year` (*int, rule `nullable|integer|between:1889,2100`), `format` (*string, rule `in:LP,2LP,CD,2CD,MC,Box,EP 7"`), `shelf` (*string); permission `golem15.fonoteka.access_albums` (admin_permissions.go). Test helpers: `bootDB(t)`, `assembleTracer(t)`, `phase10CookieAdmin(t, gdb, h, permsJSON)`, `albumsFrontend(t, gdb, email, active)`, `phase10Call(t, h, method, path, body, cookie, ajax)`, `phase10GetJSON`, `adminAPI(rel)`, `testAdminPrefix` = /plytadmin.
Tests pinning the current Albums shape: admin_albums_test.go (`len(schema.Fields) != 5` in TestAlbumsAdminRegistration; `"toolbarButtons":["create","delete"]` in TestAlbumsAdminList), admin_phase10_copy_test.go (every controller's toolbarButtons equal "create,delete"), admin_phase10_controllers_test.go (`phase10BuiltinFieldTypes` must contain every served field type; served fields must equal the tracked fields.yaml keys).
</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. Truths come from CONTEXT D-01..D-17 and the UI-SPEC "Application proof" rows.
- Discretion resolved per UI-SPEC and RESEARCH Open Questions 1 and 3: stats show the total, per-format counts and no-shelf; the widget fills `[year, format]`, which needs the new `year` field; action names `discogsLookup` and `discogsSync`; both reuse `golem15.fonoteka.access_albums` (Pitfall 12, no Winter catalog divergence); the widget renders on create and update.
- Albums has no real form partial; the form `type: partial` path is proven by the acme fixture in 10.1-01 (D-03 discretion).
- Tests here are smoke tests; the full Albums acceptance test is 10.1-04 (CLAUDE.md rule 3).
## Artifacts this phase produces
- fonoteka methods on `albumsAdminController`: `PartialData`, `AdminJS`, `AdminCSS`, `AdminActions`; view-model types `albumsStatsView` (`Total`, `Formats`, `NoShelf`) and `albumsStatItem` (`Label`, `Count`)
- Stub actions `discogsLookup` (fill `year`, `format`) and `discogsSync`
- Files: `controllers/albums/_stats.htm`, `assets/js/discogs-lookup.js` (custom element `golem15-fonoteka-discogs-lookup`), `assets/css/albums.css`
- YAML: config_list.yaml `headerPartial: stats`, `toolbar.buttons: [create, delete, discogsSync]`; fields.yaml `year` (number) and `discogs` (widget)
- Lang keys (en, pl): `golem15.fonoteka::lang.item.year`, `stats.total`, `stats.no_shelf`, `discogs.lookup_button`, `discogs.lookup_label`, `discogs.lookup_comment`, `discogs.stub_filled`, `discogs.sync_button`, `discogs.stub_not_implemented`
- Test: `TestPhase101AlbumsSmoke` (subtests stats, widget, assets, toolbar)
<tasks>
<task type="tracer">
<name>Task 1: The Albums list shows a statistics strip scoped to the admin's collection</name>
<precondition>10.1-01 and 10.1-02 are executed: `grep -q 'AdminPartialData' modules/pact/capabilities.go &amp;&amp; grep -q 'summer-stats' admin/src/styles/main.css` succeeds from summercms.go.</precondition>
<files>../fonoteka.go/plugins/golem15/fonoteka/controllers/albums_admin_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/albums/_stats.htm, ../fonoteka.go/plugins/golem15/fonoteka/controllers/albums/config_list.yaml, ../fonoteka.go/plugins/golem15/fonoteka/admin.go, ../fonoteka.go/plugins/golem15/fonoteka/lang/en/lang.yaml, ../fonoteka.go/plugins/golem15/fonoteka/lang/pl/lang.yaml, ../fonoteka.go/plugins/golem15/fonoteka/admin_phase101_smoke_test.go</files>
<read_first>../fonoteka.go/plugins/golem15/fonoteka/controllers/albums_admin_controller.go (scopeAlbums, orderedOptions nil-db idiom), ../fonoteka.go/plugins/golem15/fonoteka/models/album.go (albumFormatOptions, columns), ../fonoteka.go/plugins/golem15/fonoteka/controllers/albums/config_list.yaml, ../fonoteka.go/plugins/golem15/fonoteka/admin.go, ../fonoteka.go/plugins/golem15/fonoteka/lang/en/lang.yaml and pl/lang.yaml (item, album_format groups), ../fonoteka.go/plugins/golem15/fonoteka/admin_phase10_relations_test.go (phase10CookieAdmin), ../fonoteka.go/plugins/golem15/fonoteka/admin_albums_test.go (albumsFrontend), modules/cabana/partial_render.go and modules/cabana/README.md (template contract from 10.1-01), .planning/phases/10.1-runtime-admin-extension-point/10.1-UI-SPEC.md (Partial style kit markup, Application proof)</read_first>
<action>(1) View model (D-10, T-10.1-15): add `PartialData(ctx context.Context, name string, record any) (any, error)` to albumsAdminController. For `stats` it returns an `albumsStatsView` with `Total int`, `Formats []albumsStatItem` (`Label string` holding the format option's phrase key, `Count int`) and `NoShelf int`; any other name returns an error. Resolve the db like orderedOptions (nil db gives a zero view). Build each of three queries fresh from `db.WithContext(ctx).Model(&models.Album{})` and pass it through `c.scopeAlbums(ctx, q)` (never reuse a chained *gorm.DB between queries): a Count for Total; `Select("format, COUNT(*) AS count").Group("format")` scanned into rows; `Where("shelf IS NULL OR shelf = ''")` Count for NoShelf. Emit Formats in `models.Album{}.DropdownOptions("format")` order, keeping only counts above zero. The view model never holds an Album.
(2) Template `controllers/albums/_stats.htm` per the UI-SPEC partial style kit: a `<dl class="summer-stats">` whose first `<div class="summer-stat">` has `<dt class="summer-stat__label">{{ trans "golem15.fonoteka::lang.stats.total" }}</dt><dd class="summer-stat__value">{{ .Data.Total }}</dd>`, then `{{ range .Data.Formats }}` one item per format with `{{ trans .Label }}` and `{{ .Count }}`, then `{{ if .Data.NoShelf }}` an item with `{{ trans "golem15.fonoteka::lang.stats.no_shelf" }}` and `{{ .Data.NoShelf }}`. Kit classes only, no inline style, no plugin CSS. With zero albums it still renders the total item with 0.
(3) config_list.yaml gains top-level `headerPartial: stats`. admin.go's `//go:embed` list appends `controllers/albums/_stats.htm`. Add a `stats:` group with `total` and `no_shelf` to lang/en/lang.yaml ("All albums", "No shelf") and lang/pl/lang.yaml ("Wszystkie albumy", "Bez półki").
(4) Smoke test: new `admin_phase101_smoke_test.go` with `TestPhase101AlbumsSmoke` and its subtest `stats`: boot with bootDB and assembleTracer, create an admin with `{"golem15.fonoteka.access_albums":1}` through phase10CookieAdmin, bind its email to a collection with albumsFrontend, insert albums in that collection (two LP, one CD, one without shelf) and albums in a second collection; GET `adminAPI("/golem15/fonoteka/albums/schema/list")` shows `"headerPartial":"stats"`; GET `adminAPI("/golem15/fonoteka/albums/partials/stats")` with the cookie and locale en returns 200 whose node tree text contains "All albums", the admin's own total, "LP" with 2, "CD" with 1 and "No shelf", and never the other collection's total.</action>
<verify>
<automated>cd ../fonoteka.go &amp;&amp; go vet ./... ./plugins/golem15/fonoteka/... &amp;&amp; go test ./plugins/golem15/fonoteka -run '^(TestPhase101AlbumsSmoke|TestPhase10LangCatalog|TestAlbumsAdminList|TestAlbumsAdminRegistration|TestPhase10ControllerCopy)$' -count=1 -v</automated>
<fails_when>Non-zero exit; the output lacks "--- PASS: TestPhase101AlbumsSmoke" or "--- PASS: TestPhase101AlbumsSmoke/stats", or prints "no tests to run" or "--- SKIP"; boot fails with a "cabana: admin schema" error.</fails_when>
</verify>
<acceptance_criteria>
- `grep -c '^headerPartial: stats' ../fonoteka.go/plugins/golem15/fonoteka/controllers/albums/config_list.yaml` prints 1.
- `grep -c 'controllers/albums/_stats.htm' ../fonoteka.go/plugins/golem15/fonoteka/admin.go` prints 1.
- `grep -c 'scopeAlbums' ../fonoteka.go/plugins/golem15/fonoteka/controllers/albums_admin_controller.go` prints at least 5 (the three stats queries plus the existing list and form scopes).
- `grep -c 'summer-stat__value' ../fonoteka.go/plugins/golem15/fonoteka/controllers/albums/_stats.htm` prints at least 2 and `grep -c 'style=' ../fonoteka.go/plugins/golem15/fonoteka/controllers/albums/_stats.htm` prints 0.
- TestPhase101AlbumsSmoke/stats proves the other collection's albums are not counted.
</acceptance_criteria>
<done>An admin opening Albums gets a server-rendered statistics strip for their own collection above the list.</done>
</task>
<task type="auto">
<name>Task 2: The Albums form offers "Load from Discogs", whose stub fills Release year and Format</name>
<reversibility rating="costly">D-06: this is the first application fields.yaml written in the widget key shape; every later ported widget copies it. User-locked, flagged without a checkpoint.</reversibility>
<files>../fonoteka.go/plugins/golem15/fonoteka/models/album/fields.yaml, ../fonoteka.go/plugins/golem15/fonoteka/controllers/albums_admin_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/assets/js/discogs-lookup.js, ../fonoteka.go/plugins/golem15/fonoteka/assets/css/albums.css, ../fonoteka.go/plugins/golem15/fonoteka/admin.go, ../fonoteka.go/plugins/golem15/fonoteka/lang/en/lang.yaml, ../fonoteka.go/plugins/golem15/fonoteka/lang/pl/lang.yaml, ../fonoteka.go/plugins/golem15/fonoteka/admin_albums_test.go, ../fonoteka.go/plugins/golem15/fonoteka/admin_phase10_controllers_test.go, ../fonoteka.go/plugins/golem15/fonoteka/admin_phase101_smoke_test.go</files>
<read_first>../fonoteka.go/plugins/golem15/fonoteka/models/album/fields.yaml, ../fonoteka.go/plugins/golem15/fonoteka/controllers/albums_admin_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/admin.go, ../fonoteka.go/plugins/golem15/fonoteka/admin_albums_test.go (TestAlbumsAdminRegistration, TestAlbumsAdminForm), ../fonoteka.go/plugins/golem15/fonoteka/admin_phase10_controllers_test.go (phase10BuiltinFieldTypes, phase10TrackedKeys), ../fonoteka.go/plugins/golem15/fonoteka/admin_phase10_auth_test.go (TestPhase10LangCatalog), modules/cabana/README.md (widget, assets and CSS variable contract from 10.1-01/02), admin/tests/fixtures/extension.form-schema.json (element contract as the SPA uses it), .planning/phases/10.1-runtime-admin-extension-point/10.1-UI-SPEC.md (S3 plugin element visual contract, Copywriting Contract application rows)</read_first>
<action>(1) fields.yaml (D-06, UI-SPEC Application proof): after `shelf` add `year` (label golem15.fonoteka::lang.item.year, span right, type number), so shelf and year share a row; as the last field add `discogs` with label golem15.fonoteka::lang.discogs.lookup_label, comment golem15.fonoteka::lang.discogs.lookup_comment, type widget, widget golem15-fonoteka-discogs-lookup, action discogsLookup, fill [year, format], span full. No context key: it renders on create and update.
(2) Controller (D-02, D-13): `AdminJS()` returns `assets/js/discogs-lookup.js`; `AdminCSS()` returns `assets/css/albums.css`; `AdminActions()` returns `discogsLookup` with Label golem15.fonoteka::lang.discogs.lookup_button, Permissions [golem15.fonoteka.access_albums] and a Run that returns Message golem15.fonoteka::lang.discogs.stub_filled and Fill {"year": 1977, "format": "LP"}. A comment says Phase 14 replaces this stub with the Discogs client; the stub reads no record, no credential and makes no outbound call. Add compile-time assertions that albumsAdminController implements pact.AdminClientAssets, pact.HasAdminActions and pact.AdminPartialData.
(3) `assets/js/discogs-lookup.js` (D-04; plain ES module, no import, no Vue, no user-facing string, no network request, no cookie or storage access): a class extending HTMLElement with observedAttributes label, busy-label, busy and state. connectedCallback creates one light-DOM `<button type="button">` if it does not exist yet, with text from the `label` attribute. A click dispatches `new CustomEvent('summer-action', { bubbles: true, composed: true })` unless the element has `busy`. attributeChangedCallback keeps the button in sync: with `busy` present the button is disabled, has aria-busy="true" and shows the `busy-label` text; otherwise it is enabled and shows `label`. Define it only when `customElements.get('golem15-fonoteka-discogs-lookup')` is undefined.
(4) `assets/css/albums.css` styles only `golem15-fonoteka-discogs-lookup button` per the UI-SPEC S3 table, reading only public `--c-*` variables: height 42px, padding 0 16px, min-width 160px, border-radius 10px, 1px solid var(--c-border-strong), background var(--c-surface), color var(--c-text), `font: inherit`, font-weight 600, white-space nowrap, background var(--c-hover) on hover with a 150ms ease-out transition, `:focus-visible` outline 3px solid var(--c-ring) with offset 2px, and `[disabled]` opacity .6 with cursor not-allowed. No hex colours.
(5) admin.go's `//go:embed` list appends `assets/js/discogs-lookup.js assets/css/albums.css`. Lang en and pl, using the UI-SPEC strings: item.year ("Release year" / "Rok wydania"); in the existing discogs group lookup_button ("Load from Discogs" / "Wczytaj z Discogs"), lookup_label ("Discogs"), lookup_comment ("Fills in Release year and Format. Click Save to keep them." / "Uzupełnia Rok wydania i Format. Kliknij Zapisz, aby je zachować."), stub_filled ("Release year and Format were filled with test data. Save to keep them." / "Uzupełniono Rok wydania i Format danymi testowymi. Zapisz, aby je zachować.").
(6) Tests pinning the old shape, in the same commit: admin_albums_test.go's form field count becomes 7; admin_phase10_controllers_test.go's phase10BuiltinFieldTypes gains "widget" (the SPA ships its renderer since 10.1-02). Extend TestPhase101AlbumsSmoke with subtests `widget` (the form schema has the discogs field with widget, action and fill [year format], an actionLabel resolved to "Load from Discogs" in en and "Wczytaj z Discogs" in pl, and assets.scripts naming `/plytadmin/assets/golem15/fonoteka/js/discogs-lookup.js?v=`; creating an album then POSTing `widgets/discogs` with its record_id and values returns 200 with fill exactly {year: 1977, format: "LP"} and the resolved stub_filled message; a PUT carrying year 1977 and format LP persists both) and `assets` (GET the script and stylesheet URLs from the schema returns 200 with `text/javascript; charset=utf-8` and `text/css; charset=utf-8`, nosniff and an ETag; GET `/plytadmin/assets/golem15/fonoteka/models/album/fields.yaml` is 404).</action>
<verify>
<automated>cd ../fonoteka.go &amp;&amp; go vet ./... ./plugins/golem15/fonoteka/... &amp;&amp; go test ./plugins/golem15/fonoteka -run '^(TestPhase101AlbumsSmoke|TestPhase10LangCatalog|TestAlbumsAdminForm|TestAlbumsAdminRegistration|TestAlbumsAdminCRUD|TestPhase10Controllers|TestPhase10AlbumRelations|TestPhase10AssembledAcceptance)$' -count=1 -v</automated>
<fails_when>Non-zero exit; the output lacks a "--- PASS" line for TestPhase101AlbumsSmoke/widget, TestPhase101AlbumsSmoke/assets, TestPhase10LangCatalog, TestPhase10Controllers or TestPhase10AssembledAcceptance, or prints "no tests to run" or "--- SKIP"; boot fails with a "cabana: admin schema" error.</fails_when>
</verify>
<acceptance_criteria>
- `grep -c 'action: discogsLookup' ../fonoteka.go/plugins/golem15/fonoteka/models/album/fields.yaml` prints 1 and `grep -c 'widget: golem15-fonoteka-discogs-lookup' ../fonoteka.go/plugins/golem15/fonoteka/models/album/fields.yaml` prints 1.
- `grep -cE 'fetch\(|XMLHttpRequest|document\.cookie|localStorage|sessionStorage' ../fonoteka.go/plugins/golem15/fonoteka/assets/js/discogs-lookup.js` prints 0 and `grep -cE '^\s*import\s' ../fonoteka.go/plugins/golem15/fonoteka/assets/js/discogs-lookup.js` prints 0.
- `grep -cE '#[0-9a-fA-F]{3,6}' ../fonoteka.go/plugins/golem15/fonoteka/assets/css/albums.css` prints 0.
- `grep -c 'golem15.fonoteka.access_albums' ../fonoteka.go/plugins/golem15/fonoteka/controllers/albums_admin_controller.go` prints at least 2.
- `grep -c 'assets/js/discogs-lookup.js' ../fonoteka.go/plugins/golem15/fonoteka/admin.go` prints 1.
</acceptance_criteria>
<done>The Albums form serves a Discogs lookup widget whose stub returns Release year 1977 and Format LP into the unsaved form, with its plain-JS element and stylesheet served from the plugin's embedded files.</done>
</task>
<task type="auto">
<name>Task 3: A "Sync with Discogs" toolbar action toasts from its stub, and the three surfaces pass a browser check</name>
<reversibility rating="costly">D-12: first application list YAML to register a custom toolbar name in toolbar.buttons; user-locked, flagged without a checkpoint.</reversibility>
<files>../fonoteka.go/plugins/golem15/fonoteka/controllers/albums/config_list.yaml, ../fonoteka.go/plugins/golem15/fonoteka/controllers/albums_admin_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/lang/en/lang.yaml, ../fonoteka.go/plugins/golem15/fonoteka/lang/pl/lang.yaml, ../fonoteka.go/plugins/golem15/fonoteka/admin_albums_test.go, ../fonoteka.go/plugins/golem15/fonoteka/admin_phase10_copy_test.go, ../fonoteka.go/plugins/golem15/fonoteka/admin_phase101_smoke_test.go</files>
<read_first>../fonoteka.go/plugins/golem15/fonoteka/controllers/albums/config_list.yaml, ../fonoteka.go/plugins/golem15/fonoteka/controllers/albums_admin_controller.go (Task 2 AdminActions), ../fonoteka.go/plugins/golem15/fonoteka/admin_albums_test.go (TestAlbumsAdminList), ../fonoteka.go/plugins/golem15/fonoteka/admin_phase10_copy_test.go, ../fonoteka.go/plugins/golem15/fonoteka/admin_phase101_smoke_test.go, .planning/phases/10.1-runtime-admin-extension-point/10.1-UI-SPEC.md (S4, Copywriting Contract), .planning/phases/10.1-runtime-admin-extension-point/10.1-VALIDATION.md (Manual-Only Verifications)</read_first>
<action>(1) config_list.yaml `toolbar.buttons` becomes `[create, delete, discogsSync]` (D-12). AdminActions gains `discogsSync` with Label golem15.fonoteka::lang.discogs.sync_button, Permissions [golem15.fonoteka.access_albums] and a Run returning Message golem15.fonoteka::lang.discogs.stub_not_implemented and no fill (D-02 stub; a comment names Phase 14; it changes nothing and makes no outbound call). Lang en and pl in the discogs group: sync_button ("Sync with Discogs" / "Synchronizuj z Discogs") and stub_not_implemented ("Discogs sync is in test mode. Nothing was changed." / "Synchronizacja z Discogs działa w trybie testowym. Nic nie zmieniono.").
(2) Tests pinning the old toolbar, in the same commit: admin_albums_test.go's expected `"toolbarButtons":["create","delete"]` for Albums becomes `["create","delete","discogsSync"]`; admin_phase10_copy_test.go keeps "create,delete" for the other four controllers and expects "create,delete,discogsSync" for albums (a per-case expectation). Extend TestPhase101AlbumsSmoke with subtest `toolbar`: the developer admin's list schema has toolbarActions [{name: discogsSync, label: "Sync with Discogs"}] in en and the pl label in pl; POST `adminAPI("/golem15/fonoteka/albums/toolbar/discogsSync")` with `{}`, the cookie and X-Requested-With returns 200 with the resolved stub_not_implemented message and an empty fill; the same POST without X-Requested-With is 403; an admin whose role grants only golem15.fonoteka.access_genres gets 403 on the toolbar, widget and partial routes.
(3) Run the whole fonoteka plugin suite and the Phase 10 hygiene gate from summercms.go to confirm nothing application-specific leaked into the framework.</action>
<verify>
<automated>(cd ../fonoteka.go &amp;&amp; go vet ./... ./plugins/golem15/fonoteka/... &amp;&amp; go test ./plugins/golem15/fonoteka/... -count=1 &amp;&amp; go test ./plugins/golem15/fonoteka -run '^(TestPhase101AlbumsSmoke|TestAlbumsAdminList|TestPhase10ControllerCopy)$' -count=1 -v) &amp;&amp; scripts/check-phase10.sh --hygiene</automated>
<fails_when>Any command exits non-zero; the verbose run lacks "--- PASS: TestPhase101AlbumsSmoke/toolbar", "--- PASS: TestAlbumsAdminList" or "--- PASS: TestPhase10ControllerCopy", or prints "no tests to run" or "--- SKIP"; check-phase10.sh prints a line starting with "refuse:".</fails_when>
<human-check>
<test>In ../fonoteka.go run `summer serve` (or the built binary's serve command), open http://localhost:8080/plytadmin, log in as an admin bound to a collection and open Albumy with the browser console open. Then: (a) check the statistics strip above the list; (b) click "Synchronizuj z Discogs"; (c) open an album, click "Wczytaj z Discogs", then Zapisz and reload; (d) open the create form; (e) narrow the window to 768px in pl and repeat (a) to (c); (f) switch to another controller such as Gatunki.</test>
<expected>No CSP or module-loading error in the console (RESEARCH A4: the same-origin module script loads under script-src 'self'); the strip shows Wszystkie albumy with the collection's counts and wraps without horizontal scroll at 768px with labels such as "Cassette (MC)" and "Bez półki" readable; the toolbar action shows its test-mode toast; the widget button shows its label, turns busy during the request, fills Rok wydania 1977 and Format LP without saving, and after Zapisz and reload both values persist; the widget also renders on the create form; button labels do not truncate at 768px; on Gatunki the Albums stylesheet no longer applies.</expected>
<why_human>CSP enforcement, real module loading and custom-element upgrade happen only in a browser, and the 768px layout backstops need visual judgment; Vitest's happy-dom does not enforce CSP.</why_human>
</human-check>
</verify>
<acceptance_criteria>
- `grep -c 'buttons: \[create, delete, discogsSync\]' ../fonoteka.go/plugins/golem15/fonoteka/controllers/albums/config_list.yaml` prints 1.
- `grep -c 'discogsSync' ../fonoteka.go/plugins/golem15/fonoteka/controllers/albums_admin_controller.go` prints at least 1 and `grep -c 'stub_not_implemented' ../fonoteka.go/plugins/golem15/fonoteka/lang/pl/lang.yaml` prints 1.
- TestPhase101AlbumsSmoke/toolbar proves the limited admin's 403 on the toolbar, widget and partial routes.
- The whole `go test ./plugins/golem15/fonoteka/...` run passes.
</acceptance_criteria>
<done>The Albums list offers a permission-gated "Sync with Discogs" action that toasts from its stub, and the three Albums surfaces are ready for the in-browser check.</done>
</task>
</tasks>
## Source coverage (this plan)
| Source | Item | Task |
|--------|------|------|
| CONTEXT | D-01 proof on Albums (strip, widget, toolbar) | 1, 2, 3 |
| CONTEXT | D-02 enabled widget button, stub endpoint with fixture payload | 2, 3 |
| CONTEXT | D-03 strip is list chrome via headerPartial | 1 |
| CONTEXT | D-04 plain JS custom element | 2 |
| CONTEXT | D-06, D-07 widget YAML shape and fill write-back | 2 |
| CONTEXT | D-10 curated view model, escaping on | 1 |
| CONTEXT | D-11 headerPartial declared in config_list.yaml | 1 |
| CONTEXT | D-12 third toolbar action registered on the controller | 3 |
| CONTEXT | D-13 to D-16 controller JS/CSS through AdminJS/AdminCSS, embedded, same-origin | 2 |
| UI-SPEC | Application proof rows, S1 populated/zero/overflow, backstops | 1-3 |
| RESEARCH | Open Questions 1, 3; Pitfalls 3, 11, 12, 14; A4 manual UAT | 1-3 |
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Admin session → Albums stats view model | Counts must stay inside the admin's resolved collection |
| Plugin JS → admin origin | Same-origin script shipped by the application |
| Admin → Discogs stub actions | Stub endpoints reachable by any admin who passes the Albums permission |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-10.1-10 | Elevation of Privilege | Albums plugin JS running in the admin origin | medium | mitigate | Plugin JS is trusted compiled code (like plugin Go); the element makes no network request and reads no cookie or storage, and signals only through summer-action (D-05); the session cookie is HttpOnly; 10.1-04's hygiene stage scans plugin asset JS for network and cookie access (Task 2). |
| T-10.1-15 | Information Disclosure | Albums statistics strip | high | mitigate | Every stats query is built fresh and scoped with scopeAlbums (collection binding, `1 = 0` when unbound); the view model holds labels and integers only; the smoke test and 10.1-04's two-collection test prove isolation (Task 1). |
| T-10.1-21 | Elevation of Privilege | discogsLookup and discogsSync stubs | medium | mitigate | Both require golem15.fonoteka.access_albums on top of the controller permission; a Genres-only admin gets 403 (Task 3). |
| T-10.1-22 | Tampering | stub fill payload | low | accept | The stub only patches the unsaved form; nothing persists until the admin saves, and the save runs the Album rules (year between 1889 and 2100, format in the option list). |
| T-10.1-SC | Tampering | dependencies | high | mitigate | No Go module or npm package added in fonoteka.go; the element is hand-written plain JS. |
</threat_model>
<verification>
After Task 3: `go vet ./... ./plugins/golem15/fonoteka/...` and `go test ./plugins/golem15/fonoteka/...` pass in fonoteka.go; TestPhase101AlbumsSmoke covers stats, widget, assets and toolbar; `scripts/check-phase10.sh --hygiene` passes in summercms.go. The browser check in Task 3 is collected at /gsd-verify-work.
</verification>
<success_criteria>
- The Albums list shows a collection-scoped statistics strip; the Albums form shows the Discogs lookup widget filling Release year and Format; the Albums toolbar offers Sync with Discogs, both Discogs actions as stubs.
- All application copy exists in pl and en; the Phase 10 Albums tests reflect the new shape; no framework file names the application.
</success_criteria>
<output>
Create `.planning/phases/10.1-runtime-admin-extension-point/10.1-03-SUMMARY.md` when done.
</output>

View File

@@ -0,0 +1,288 @@
---
phase: 10.1-runtime-admin-extension-point
plan: 04
type: execute
wave: 4
depends_on: [10.1-01, 10.1-02, 10.1-03]
files_modified:
- modules/cabana/testdata/extension/**
- modules/cabana/phase101_schema_test.go
- modules/cabana/phase101_render_test.go
- modules/cabana/phase101_assets_test.go
- modules/cabana/phase101_actions_test.go
- modules/boardwalk/boardwalk_test.go
- ../fonoteka.go/plugins/golem15/fonoteka/admin_phase101_albums_test.go
- admin/tests/fixtures/extension.form-schema.json
- admin/tests/fixtures/extension.list-schema.json
- admin/tests/fixtures/extension.partial.json
- admin/tests/fixtures/typed.ts
- admin/tests/app/pluginAssets.test.ts
- admin/tests/form/WidgetField.test.ts
- admin/tests/form/PartialField.test.ts
- admin/tests/list/PartialHost.test.ts
- admin/tests/list/ListToolbar.test.ts
- admin/tests/list/ListView.test.ts
- admin/tests/form/registry.test.ts
- admin/tests/form/formState.test.ts
- admin/tests/form/FormField.test.ts
- admin/tests/form/FormView.test.ts
- scripts/check-phase10.1.sh
- .planning/phases/10.1-runtime-admin-extension-point/10.1-SECURITY-REVIEW.md
- .planning/phases/10.1-runtime-admin-extension-point/10.1-VALIDATION.md
autonomous: true
requirements: [ADMIN-07]
estimate:
tokens: 140000
raw_tokens: 140000
tasks: 3
confidence: low
must_haves:
truths:
- "Per CLAUDE.md rule 3, every Phase 10.1 Go change has named tests covering success and failure branches: TestPhase101FormExtensionSchema, TestPhase101PartialSchema, TestPhase101Toolbar, TestPhase101PartialSanitizer, TestPhase101Assets and TestPhase101Actions in cabana, TestPhase101BoardwalkExports in boardwalk, and TestPhase101AlbumsExtension in fonoteka.go; both repositories pass go vet ./... and their test suites."
- "TestPhase101AlbumsExtension proves on real PostgreSQL through the assembled router at /plytadmin: the stats strip counts only the admin's collection (two collections), shows All albums 0 with no format items for an empty collection and No shelf only when above zero; the Discogs widget fills year 1977 and format LP and a save persists them; discogsSync toasts; a Genres-only admin gets 403 on the widget, toolbar and partial routes; the declared assets are served with JavaScript and CSS types; every rendered label resolves in pl and en; every route template it calls is in admin/openapi/admin.json."
- "Every new SPA module and changed component has a Vitest suite (pluginAssets, WidgetField, PartialField, PartialHost with partialNodes, ListToolbar, ListView, registry, formState, FormField, FormView) asserting the UI-SPEC states and accessibility attributes with neutral acme fixtures, and npm --prefix admin test passes offline."
- "Assumption-delta invariant: TestPhase101Toolbar proves every toolbar.buttons name resolves to exactly one built-in or registered action, and a registered action named create or delete fails boot."
- "scripts/check-phase10.1.sh --all exits non-zero on a failing, skipped or zero-test go run, a named test that did not pass, OpenAPI or dist drift, a hygiene violation (Phase 10 rules plus HTML-string parsers in admin/src, network, cookie or storage access in application plugin asset JS, and script or event-handler markup in application partial templates) or an evidence gap; --self-test proves each detector and hygiene rule fails closed."
- "10.1-SECURITY-REVIEW.md lists T-10.1-01 to T-10.1-22 and T-10.1-SC with severity, disposition, production mitigation, the exact failing-when-broken test or gate stage and the observed result; every high threat records a removal check; 10.1-VALIDATION.md maps every 10.1 plan task to its command with nyquist_compliant true only after the gate passes; scripts/check-phase10.sh --all still passes."
artifacts:
- path: "scripts/check-phase10.1.sh"
provides: "Fail-closed Phase 10.1 gate with self-test, go, security, postgres, spa, openapi, dist, hygiene, evidence and all stages"
- path: "../fonoteka.go/plugins/golem15/fonoteka/admin_phase101_albums_test.go"
provides: "Assembled PostgreSQL acceptance of the three Albums surfaces"
- path: "modules/cabana/phase101_render_test.go"
provides: "Sanitizer, escaping, caps and model-guard coverage"
- path: ".planning/phases/10.1-runtime-admin-extension-point/10.1-SECURITY-REVIEW.md"
provides: "Threat-to-test evidence ledger"
key_links:
- from: "scripts/check-phase10.1.sh"
to: "go test -json output"
via: "phase101_detect refusing fail, skip, zero-test, non-JSON and missing required tests"
pattern: "phase101_detect"
- from: "scripts/check-phase10.1.sh"
to: "scripts/check-phase10.sh --hygiene"
via: "--hygiene stage reuses the Phase 10 rules before the 10.1 rules"
pattern: "check-phase10.sh --hygiene"
- from: "10.1-VALIDATION.md"
to: "10.1-01..10.1-04 task verify commands"
via: "per-task verification map"
pattern: "10.1-0"
prohibitions:
- "Phase acceptance must not rest on skipped PostgreSQL tests, zero-test runs, or a hand-edited dist or schema.d.ts."
- "No test or fixture inside summercms.go uses application names; application names appear only in fonoteka.go tests."
- "No high threat is marked mitigated without a named test or gate stage that fails when the mitigation is removed."
- "No coverage-provider or other package is added."
---
## 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>
Close Phase 10.1 with full unit test coverage for its Go and SPA code, an assembled Albums acceptance test, one fail-closed gate script, and the security review and validation evidence.
Purpose: CLAUDE.md rule 3 (unit tests are the last plan of a phase); Plans 10.1-01 to 10.1-03 carried smoke tests only. The gate makes phase acceptance a single command.
Output: cabana, boardwalk and fonoteka Go tests with an acme testdata tree; Vitest suites; `scripts/check-phase10.1.sh`; `10.1-SECURITY-REVIEW.md`; finalized `10.1-VALIDATION.md`.
Repos: Task 1 summercms.go and fonoteka.go (the Albums test is committed in fonoteka.go); Task 2 summercms.go; Task 3 summercms.go (script) plus planning docs in a separate docs commit. 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/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-UI-SPEC.md
@.planning/phases/10.1-runtime-admin-extension-point/10.1-VALIDATION.md
@.planning/phases/10.1-runtime-admin-extension-point/10.1-01-SUMMARY.md
@.planning/phases/10.1-runtime-admin-extension-point/10.1-02-SUMMARY.md
@.planning/phases/10.1-runtime-admin-extension-point/10.1-03-SUMMARY.md
@.planning/phases/10-admin-vue-spa/10-SECURITY-REVIEW.md
@scripts/check-phase10.sh
<interfaces>
Gate pattern to reuse: scripts/check-phase10.sh (`phase10_detect` parses `go test -json`: exit 1 fail, 2 skip, 3 zero tests, 4 non-JSON, 5 a required test did not pass, 6 an allow-listed failure now passes; `phase10_go DIR PKGS...`; `phase10_tests DIR PKG TEST...`; `hygiene_checks TREE APPTREE`; `--self-test` scratch-copy plants; `KNOWN_APP_FAILURES` allow-lists exactly the two fonoteka parity failures TestMigrateSeedsCanonicalGenres and TestSchemaMatchesPHPSnapshot). check-phase10.sh ends with a case dispatch and cannot be sourced; copy the detector as `phase101_detect`.
Test harnesses: cabana `adminGorm(t)` and `newConformEnv(t)` (openapi_conformance_test.go, testcontainers PostgreSQL), `phase09DeniedService()`, `handlerRouter`, `csrfRequest` (phase10_csrf_test.go); fonoteka `bootDB`, `assembleTracer`, `phase10CookieAdmin`, `albumsFrontend`, `phase10Call`, `phase10GetJSON`, `phase10OpenAPIPaths` (admin_phase10_e2e_test.go); Vitest `tests/helpers.ts` (`mountApp`, `requestsTo`, `queryOf`, API `/admin-test/api/v1`), typed fixtures in `tests/fixtures/typed.ts`.
Threat IDs in this phase: T-10.1-01..09, 11, 12 (10.1-01); T-10.1-13, 14, 16, 17, 18 (10.1-02); T-10.1-10, 15, 21, 22 (10.1-03); T-10.1-19, 20 (this plan); T-10.1-SC (all plans).
</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. Truths come from CONTEXT D-01..D-17 and the UI-SPEC.
- 10.1-VALIDATION.md seeded `tests/views/ListView.test.ts`; the real suite is `admin/tests/list/ListView.test.ts`. Task 3 corrects the row.
## Artifacts this phase produces
- Go tests: `TestPhase101FormExtensionSchema`, `TestPhase101PartialSchema`, `TestPhase101Toolbar` (modules/cabana/phase101_schema_test.go), `TestPhase101PartialSanitizer` (phase101_render_test.go), `TestPhase101Assets` (phase101_assets_test.go), `TestPhase101Actions` (phase101_actions_test.go), `TestPhase101BoardwalkExports` (modules/boardwalk/boardwalk_test.go), `TestPhase101AlbumsExtension` (fonoteka.go admin_phase101_albums_test.go)
- Fixture tree `modules/cabana/testdata/extension/` (controllers/gadgets config and `_stats.htm`, `_summary.htm`; models/gadget fields and columns; `assets/js/lookup.js`, `assets/css/gadgets.css`)
- Vitest suites `tests/app/pluginAssets.test.ts`, `tests/form/WidgetField.test.ts`, `tests/form/PartialField.test.ts`, `tests/list/PartialHost.test.ts`, extended ListToolbar, ListView, registry, formState, FormField and FormView suites
- `scripts/check-phase10.1.sh` with modes `--self-test`, `--go`, `--security`, `--postgres`, `--spa`, `--openapi`, `--dist`, `--hygiene`, `--evidence`, `--all`; functions `phase101_detect`, `phase101_go`, `phase101_tests`, `hygiene_101`
- `.planning/phases/10.1-runtime-admin-extension-point/10.1-SECURITY-REVIEW.md`; finalized `10.1-VALIDATION.md` (`nyquist_compliant: true`, `wave_0_complete: true`)
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Cover every Phase 10.1 Go change and prove the Albums surfaces end to end</name>
<files>modules/cabana/testdata/extension/**, modules/cabana/phase101_schema_test.go, modules/cabana/phase101_render_test.go, modules/cabana/phase101_assets_test.go, modules/cabana/phase101_actions_test.go, modules/boardwalk/boardwalk_test.go, ../fonoteka.go/plugins/golem15/fonoteka/admin_phase101_albums_test.go</files>
<read_first>modules/pact/capabilities.go, modules/cabana/extension.go, modules/cabana/actions.go, modules/cabana/plugin_assets.go, modules/cabana/partial_render.go, modules/cabana/form_schema.go, modules/cabana/list_schema.go, modules/cabana/settings.go, modules/cabana/messages.go, modules/cabana/http.go, modules/cabana/openapi_conformance_test.go (conform fixture, newConformEnv), modules/cabana/form_schema_test.go (boot-error table idiom), modules/cabana/phase10_csrf_test.go, modules/boardwalk/boardwalk.go, modules/boardwalk/boardwalk_test.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/albums_admin_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/admin_phase101_smoke_test.go, ../fonoteka.go/plugins/golem15/fonoteka/admin_phase10_e2e_test.go (phase10Acceptance, phase10OpenAPIPaths)</read_first>
<behavior>
- TestPhase101FormExtensionSchema: a valid widget compiles with Widget, Action, Fill and ActionLabel; boot fails, naming plugin, controller and file, for a widget key on a text field, type widget without widget or action, a tag without a hyphen, with uppercase, with another plugin's prefix, a reserved name (plugin ID `font.face` with tag `font-face-src`), an unregistered action, a registered action named create or delete, a nil Run, a duplicate action, a fill key that is not a field, is protected (collection_id), is a relation field or repeats, a widget on a controller without AdminJS, an unknown key, and a widget or partial in a settings form; an unknown action permission fails compileContributions; an action label phrase key that does not resolve fails validateMessageKeys while a literal label passes.
- TestPhase101PartialSchema: headerPartial and a form partial compile; boot fails for a non-identifier headerPartial, a missing `_name.htm`, a template parse error, a controller without AdminPartialData, a partial without path, `$/…`, `~/…` and `a/b` paths (with the partial-name hint), and `path` on another type.
- TestPhase101Toolbar: a registered name compiles in declared order; an unknown name fails with "unsupported action"; create and delete keep Phase 10 behaviour (delete needs showCheckboxes; create dropped without a form while custom names stay); a toolbar action without a label fails; toolbarActions labels localize per request and are filtered to actions the principal may run; invariant: every toolbar name resolves to exactly one built-in or registered action.
- TestPhase101PartialSanitizer: each dropped-with-subtree tag (script, style, template, iframe, object, embed, noscript, textarea, title, xmp, svg, math, form, input, button, select, link, meta, base) is gone with its children; unknown elements are unwrapped with children kept; on*, style and id attributes are dropped; class, title, lang, dir, role, aria-* and data-* are kept; href "/x" and "#top" are kept, "//x", "/\x", "javascript:…" and "https://x" are dropped; img src "/a.png" is kept, "data:…" dropped; td colspan, time datetime and meter attributes are kept; a view-model string holding markup renders as a text node; `trans` resolves en and pl on two requests against the same compiled partial (proving the per-request Clone and a never-executed pristine template); output over 64 KiB, over 2000 nodes and deeper than 32 each return an error; comments are dropped; a view model of the model type, a pointer to it or a slice of it is refused.
- TestPhase101Assets: an exact hit serves JS and CSS with `text/javascript; charset=utf-8` / `text/css; charset=utf-8`, nosniff, the CSP containing `script-src 'self'`, CORP same-origin, `Cache-Control: no-cache` and an ETag; If-None-Match answers 304; HEAD has no body; an undeclared fixture file (YAML, `_stats.htm`) and an encoded traversal fall through to the SPA handler and are not served; a dist `assets/index-*.js` still comes from the SPA handler; boot fails for a path outside `assets/`, a `..` segment, a `.txt` file, a stylesheet in AdminJS, a missing file, a duplicate path and a three-segment plugin ID; schema URLs carry `?v=` plus 12 hex characters; two controllers of one plugin declaring the same file share one entry.
- TestPhase101Actions (PostgreSQL): the widget POST with an in-scope record returns only fill keys and the action receives only fill-key scalar Values; an out-of-scope record_id is 404; no record_id passes a nil Record; an unknown body key, trailing JSON and a negative record_id are 422; a non-widget or unknown field is 404; missing action permission with the controller permission is 403; an action ValidationError is 422 with its details; a plain action error is 500 without its text in the body; cookie-only POSTs without X-Requested-With are 403 on both action routes; a toolbar name not in toolbar.buttons is 404 and a toolbar body with record_id or values is 422; an undeclared partial is 404, `?id=` on a header partial is 404, `?id=abc` is 404, an out-of-scope id is 404, a PartialData error is 500.
- TestPhase101BoardwalkExports: ContentType for .js, .mjs, .css, .woff2 and an unknown extension; SetSecurityHeaders sets nosniff, the CSP, X-Frame-Options DENY, Referrer-Policy and X-Robots-Tag.
- TestPhase101AlbumsExtension: as stated in must_haves.
</behavior>
<action>Write the tests in `<behavior>`. Build `modules/cabana/testdata/extension/` as an acme fixture tree (controllers/gadgets config_list.yaml with headerPartial and a registered toolbar action, config_form.yaml, `_stats.htm`, `_summary.htm`; models/gadget fields.yaml with a widget and a partial, columns.yaml; `assets/js/lookup.js` and `assets/css/gadgets.css`) loaded with os.DirFS for the schema, sanitizer and asset tests, and use table cases with fstest.MapFS variants for the boot-error rows (the form_schema_test.go idiom). Database cases reuse the testcontainers helpers (adminGorm or newConformEnv) and never an in-memory substitute. The fonoteka test seeds two collections (albumsFrontend for the admin's email plus a second user's collection), a developer-permission admin and a Genres-only admin through phase10CookieAdmin, calls every route through the assembled router at /plytadmin with the cookie and X-Requested-With, records each called path template, and finally checks them against phase10OpenAPIPaths. For each high threat in 10.1-01 to 10.1-03, confirm the named test fails when the mitigation is removed (temporarily edit the production code, run, restore) and note the result for Task 3. Neutral names only in summercms.go (acme, gadgets, lookup).</action>
<verify>
<automated>go vet ./... &amp;&amp; go test ./modules/cabana ./modules/boardwalk -run '^(TestPhase101FormExtensionSchema|TestPhase101PartialSchema|TestPhase101Toolbar|TestPhase101PartialSanitizer|TestPhase101Assets|TestPhase101Actions|TestPhase101BoardwalkExports)$' -count=1 -v &amp;&amp; go test ./... -count=1 &amp;&amp; (cd ../fonoteka.go &amp;&amp; go vet ./... ./plugins/golem15/fonoteka/... &amp;&amp; go test ./plugins/golem15/fonoteka -run '^(TestPhase101AlbumsExtension|TestPhase101AlbumsSmoke)$' -count=1 -v &amp;&amp; go test ./plugins/golem15/fonoteka/... -count=1)</automated>
<fails_when>Any command exits non-zero; the verbose runs lack a "--- PASS" line for any of TestPhase101FormExtensionSchema, TestPhase101PartialSchema, TestPhase101Toolbar, TestPhase101PartialSanitizer, TestPhase101Assets, TestPhase101Actions, TestPhase101BoardwalkExports, TestPhase101AlbumsExtension or TestPhase101AlbumsSmoke, or print "no tests to run" or "--- SKIP".</fails_when>
</verify>
<acceptance_criteria>
- Each behaviour above is a named, passing test; TestPhase101Actions and TestPhase101AlbumsExtension run against real PostgreSQL.
- `test -f modules/cabana/testdata/extension/assets/js/lookup.js &amp;&amp; test -f modules/cabana/testdata/extension/controllers/gadgets/_stats.htm` succeeds.
- `scripts/check-phase10.sh --hygiene` exits 0 (no application names in modules/cabana tests or testdata).
- Both repositories pass `go vet ./...`; summercms.go passes `go test ./...` and fonoteka.go passes `go test ./plugins/golem15/fonoteka/...`.
</acceptance_criteria>
<done>Every Go path added in Phase 10.1 has branch-level tests, and one assembled test proves the three Albums surfaces, their scoping and permissions on PostgreSQL.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Bring every new SPA module and changed component under Vitest</name>
<files>admin/tests/fixtures/extension.form-schema.json, admin/tests/fixtures/extension.list-schema.json, admin/tests/fixtures/extension.partial.json, admin/tests/fixtures/typed.ts, admin/tests/app/pluginAssets.test.ts, admin/tests/form/WidgetField.test.ts, admin/tests/form/PartialField.test.ts, admin/tests/list/PartialHost.test.ts, admin/tests/list/ListToolbar.test.ts, admin/tests/list/ListView.test.ts, admin/tests/form/registry.test.ts, admin/tests/form/formState.test.ts, admin/tests/form/FormField.test.ts, admin/tests/form/FormView.test.ts</files>
<read_first>admin/src/app/pluginAssets.ts, admin/src/components/form/formContext.ts, admin/src/components/form/fields/WidgetField.vue, admin/src/components/form/fields/PartialField.vue, admin/src/components/partial/PartialHost.vue, admin/src/components/partial/partialNodes.ts, admin/src/components/form/registry.ts, admin/src/components/form/FormField.vue, admin/src/components/list/ListToolbar.vue, admin/src/views/ListView.vue, admin/src/views/FormView.vue, admin/tests/helpers.ts, admin/tests/fixtures/typed.ts, admin/tests/smoke/extension.smoke.test.ts, admin/tests/list/ListToolbar.test.ts, .planning/phases/10.1-runtime-admin-extension-point/10.1-UI-SPEC.md (S1-S6, UI Considerations)</read_first>
<behavior>
- pluginAssets: URLs outside `${runtime.base}/assets/` (other origin, protocol-relative, another prefix) are refused; the same URL twice appends one script and returns one promise; an error rejects, removes the entry and a retry appends a new element; loadStyles tags links with the controller; activateStyles disables other controllers' links and enables its own; loadControllerAssets resolves after the scripts load.
- WidgetField: skeleton and aria-busy while loading; a 5000 ms timeout (fake timers) and a script error each show the widget_failed box with role=alert; attributes record-id, field-name, locale, fill-values, label and busy-label are set and no property or function is assigned to the element; fill-values follows form value changes; summer-action POSTs {record_id, values}; a repeat event while busy sends nothing; success patches only fill keys present in the response and toasts; failure toasts danger with the server message or action_failed and sets state="error"; on create record-id is ""; unmount removes the listener.
- PartialHost with partialNodes: an exhaustive allowlist table (allowed and dropped tags and attributes, href and src rules, depth cap), text stays text; loading skeleton sizes for header (80px) and field (44px); empty renders nothing; error shows partial_failed; a reloadKey change keeps the previous nodes with aria-busy; the id query is sent only with recordId.
- PartialField: fetch without id on create and with id on update; label span and role=group with a label; no label row without one.
- ListToolbar: custom names render after delete in declared order as outline buttons, enabled without a selection, disabled with aria-busy while busy, and emit action; names missing from actions do not render.
- ListView: the header partial slot appears only with headerPartial; it refetches after bulk delete and after a custom action but not after search, sort, filter or page changes; a custom action POSTs `{}`, toasts and reloads; a failure toasts danger; assets load when the schema arrives.
- registry and formState: widget and partial are registered, not in isRegistered, not needsRecord, groupLabelled; editablePayload omits them.
- FormField and FormView: widget and partial rows use the span label; FormView provides values, patch and locale, patch marks the form dirty and clears the field's errors, widgets and partials render on create, assets load when the schema arrives.
</behavior>
<action>Write the Vitest suites listed in `<files>` with @vue/test-utils and happy-dom, mocking HTTP through the fetch mock in tests/helpers.ts (never a real network) and defining test custom elements where a widget must upgrade. Keep fixtures neutral (acme.demo.*) and typed through typed.ts against the generated schemas so drift fails typecheck. Assert behaviour and the accessibility attributes named in `<behavior>`, not markup snapshots. Keep the smoke tests. Add no package (no coverage provider).</action>
<verify>
<automated>npm --prefix admin run typecheck &amp;&amp; npm --prefix admin test -- tests/app tests/form tests/list tests/smoke &amp;&amp; npm --prefix admin test</automated>
<fails_when>Non-zero exit; vitest prints "No test files found", any "FAIL" line or "Unhandled Rejection"; vue-tsc reports an error.</fails_when>
</verify>
<acceptance_criteria>
- Every `.ts` and `.vue` file added or changed under admin/src in Phase 10.1 is imported by a suite in admin/tests/app, admin/tests/form or admin/tests/list (not only by the smoke test).
- `grep -c 'whenDefined\|5000' admin/tests/form/WidgetField.test.ts` prints at least 1 and `grep -c 'javascript:' admin/tests/list/PartialHost.test.ts` prints at least 1.
- `git diff --quiet b2845e016b0d6a89eac744edb690b44f3f443deb -- admin/package.json admin/package-lock.json` succeeds (no package change since Phase 10.1 planning).
</acceptance_criteria>
<done>The SPA side of the extension point has behaviour and accessibility tests that run offline in seconds.</done>
</task>
<task type="auto">
<name>Task 3: One fail-closed Phase 10.1 gate plus threat and validation evidence</name>
<files>scripts/check-phase10.1.sh, .planning/phases/10.1-runtime-admin-extension-point/10.1-SECURITY-REVIEW.md, .planning/phases/10.1-runtime-admin-extension-point/10.1-VALIDATION.md</files>
<read_first>scripts/check-phase10.sh, scripts/check-phase10.2.sh, scripts/check-admin-openapi.sh, scripts/check-admin-dist.sh, .planning/phases/10.1-runtime-admin-extension-point/10.1-VALIDATION.md, .planning/phases/10-admin-vue-spa/10-SECURITY-REVIEW.md (format and removal-check table), every 10.1-0N-PLAN.md threat_model and verify block, every 10.1-0N-SUMMARY.md</read_first>
<action>(1) `scripts/check-phase10.1.sh` (bash, `set -euo pipefail`, committed executable), modelled on scripts/check-phase10.sh: ROOT, APP (../fonoteka.go), PHASE_DIR, REVIEW, VALIDATION, APP_PLUGINS and the same KNOWN_APP_FAILURES allow-list; `phase101_detect` copied from phase10_detect with PHASE101_ALLOW and PHASE101_REQUIRE; `phase101_go` and `phase101_tests`. Stages:
- `--self-test`: `bash -n`; the detector's synthetic pass, fail, skip, zero, non-JSON, build-fail, package-fail, required-missing, allowed, allowed-other, wrong-package and now-passes cases; every mode flag present in the case dispatch; hygiene_101 passes on a clean scratch copy and refuses, each for its own named reason, three plants: an HTML-string parser call in a scratch admin/src module, a network call in a scratch plugin asset JS file, and a script element in a scratch partial template.
- `--go`: go vet and go test ./... in summercms.go; go vet and go test ./... plus APP_PLUGINS in fonoteka.go with the allow-list.
- `--security`: cabana TestPhase10CSRF, TestPhase09PermissionMatrix, TestPhase101Assets, TestPhase101PartialSanitizer, TestPhase101Actions, TestPhase101FormExtensionSchema, TestPhase101Toolbar; boardwalk package; fonoteka TestPhase09SecurityRoutes.
- `--postgres`: cabana TestPhase10OpenAPIConformance and TestPhase101Actions; fonoteka TestPhase101AlbumsExtension, TestPhase101AlbumsSmoke, TestPhase10Controllers, TestPhase10ControllerCopy, TestAlbumsAdminForm, TestAlbumsAdminList, TestPhase10AssembledAcceptance (a skip or zero match fails).
- `--spa`: `npm --prefix admin ci --no-audit --no-fund`, typecheck, `npm --prefix admin test` refusing "No test files found", "FAIL " and unhandled errors.
- `--openapi`: `scripts/check-admin-openapi.sh --check` plus cabana TestPhase10OpenAPIConformance and TestPhase09ContractInventory.
- `--dist`: `scripts/check-admin-dist.sh`.
- `--hygiene`: `scripts/check-phase10.sh --hygiene`, then `hygiene_101 "$ROOT" "$APP"`, which refuses: `setHTML`, `setHTMLUnsafe`, `createContextualFragment`, `DOMParser`, `srcdoc` or `document.write` anywhere in admin/src; `fetch(`, `XMLHttpRequest`, `document.cookie`, `localStorage`, `sessionStorage` or `indexedDB` in any `plugins/*/*/assets/**/*.js` of the application; `<script`, a `style=` attribute or an inline `on…=` handler in any application `controllers/**/_*.htm` partial template.
- `--evidence`: the review exists and has a table row for every T-10.1-01 to T-10.1-22 and T-10.1-SC, and a high mitigated row names a Test*, check-phase*.sh stage or tests/ path; the validation has `nyquist_compliant: true`, no table row still carrying the seeded pending status, and names ADMIN-07; then runs --security, --postgres and --openapi.
- `--all`: every stage in order, then prints "phase10.1 all passed".
(2) `10.1-SECURITY-REVIEW.md` (frontmatter phase "10.1", reviewed date, threats_open, gate `scripts/check-phase10.1.sh --all`): a fresh code-and-test review of every threat in the four plans' registers (T-10.1-01 to T-10.1-22, T-10.1-SC) with category, component, severity, disposition, the production mitigation with file references, the exact test or gate stage, the observed result and residual risk; a removal-check table for each high threat (the mutation made, the command run, the observed failure) from Task 1's checks; accepted threats keep their rationale verbatim from the originating plan. Note that T-10-16's residual now reads "plugin HTML reaches the DOM only as a sanitized node tree" and that T-10-04 needed no framing carve-out.
(3) `10.1-VALIDATION.md`: replace the TBD rows with the actual plan and task IDs and commands from the executed plans (correcting `tests/views/ListView.test.ts` to `tests/list/ListView.test.ts`), record each row's status from the final gate, tick the Wave 0 items, keep the manual-only row pointing at the human-check blocks of 10.1-02 Task 3 and 10.1-03 Task 3, and set `nyquist_compliant: true`, `wave_0_complete: true` and `status: validated` only after `scripts/check-phase10.1.sh --all` passes.
(4) Run `scripts/check-phase10.1.sh --all`, then `scripts/check-phase10.sh --all` to prove Phase 10's gate is still green. Commit the script with the code and the two documents in a separate docs commit.</action>
<verify>
<automated>scripts/check-phase10.1.sh --self-test &amp;&amp; scripts/check-phase10.1.sh --all &amp;&amp; scripts/check-phase10.sh --all</automated>
<fails_when>Non-zero exit; any output line starting with "refuse:"; the final lines "phase10.1 all passed" or "phase10 all passed" are absent.</fails_when>
</verify>
<acceptance_criteria>
- `scripts/check-phase10.1.sh --all` prints "phase10.1 all passed" and `scripts/check-phase10.sh --all` prints "phase10 all passed".
- `test -x scripts/check-phase10.1.sh` succeeds.
- `grep -cE '^\| T-10\.1-(0[1-9]|1[0-9]|2[0-2]|SC) ' .planning/phases/10.1-runtime-admin-extension-point/10.1-SECURITY-REVIEW.md` prints 23.
- `grep -c '^nyquist_compliant: true$' .planning/phases/10.1-runtime-admin-extension-point/10.1-VALIDATION.md` prints 1, `grep -E '^\|' .planning/phases/10.1-runtime-admin-extension-point/10.1-VALIDATION.md | grep -ci 'pending'` prints 0 (table rows only; the status legend line is not a row), and `grep -c 'ADMIN-07' .planning/phases/10.1-runtime-admin-extension-point/10.1-VALIDATION.md` prints at least 1.
</acceptance_criteria>
<!-- planner-discipline-allow: pending -->
<done>Phase 10.1 has one command that proves everything, Phase 10's gate still passes, and the threat and validation evidence is auditable.</done>
</task>
</tasks>
## Multi-Source Coverage Audit (phase 10.1)
| Source | Item | Coverage | Plan evidence |
|--------|------|----------|---------------|
| GOAL | Plugins extend the compiled SPA without a Node rebuild (assets, widgets, partials, toolbar actions), proven on a nameless fixture and on three Albums surfaces | COVERED | 10.1-01 (framework Go), 10.1-02 (SPA), 10.1-03 (Albums), 10.1-04 (tests, gate) |
| REQ | ADMIN-07 | COVERED | 10.1-01, 10.1-02, 10.1-03, 10.1-04 |
| GOAL | SC-1 assets under {backend.uri}/assets, exact allowlist, per-controller load, CSP unchanged | COVERED | 10.1-01 Task 2; 10.1-02 Tasks 1, 3; 10.1-04 TestPhase101Assets, pluginAssets suite |
| GOAL | SC-2 widget mounts, SPA posts with cookie and CSRF, only fill keys patched | COVERED | 10.1-01 Task 1; 10.1-02 Task 1; 10.1-03 Task 2; 10.1-04 TestPhase101Actions, WidgetField suite |
| GOAL | SC-3 headerPartial and form partial via html/template, allowlisted node tree | COVERED | 10.1-01 Task 3; 10.1-02 Task 2; 10.1-03 Task 1; 10.1-04 TestPhase101PartialSanitizer, PartialHost suite |
| GOAL | SC-4 registered toolbar actions; unknown keys, templates, actions, permissions fail boot | COVERED | 10.1-01 Tasks 1-3; 10.1-02 Task 3; 10.1-03 Task 3; 10.1-04 schema and toolbar tests |
| GOAL | SC-5 Albums strip, Discogs widget stub, Discogs sync stub | COVERED | 10.1-03 Tasks 1-3; 10.1-04 TestPhase101AlbumsExtension |
| CONTEXT | D-01 Albums proof plus nameless fixture | COVERED | 10.1-01 fixture; 10.1-03 |
| CONTEXT | D-02 enabled widget, stub endpoints, fixture payload | COVERED | 10.1-03 Tasks 2, 3 |
| CONTEXT | D-03 strip is list chrome; form partial proven by fixture | COVERED | 10.1-01 Task 3; 10.1-02 Task 2; 10.1-03 Task 1 |
| CONTEXT | D-04 plain JS custom elements, no Node for apps | COVERED | 10.1-02 Task 1; 10.1-03 Task 2 |
| CONTEXT | D-05 SPA owns HTTP, CustomEvent, cookie plus CSRF | COVERED | 10.1-01 Task 1; 10.1-02 Task 1 |
| CONTEXT | D-06 Winter-shaped widget keys, unknown keys fail boot | COVERED | 10.1-01 Task 1; 10.1-03 Task 2 |
| CONTEXT | D-07 patch only fill keys, fixture payload saves | COVERED | 10.1-01 Task 1; 10.1-02 Task 1; 10.1-03 Task 2; 10.1-04 |
| CONTEXT | D-08 element attributes, no token or cookie | COVERED | 10.1-02 Task 1 |
| CONTEXT | D-09 list-header and form partials, widget type added | COVERED | 10.1-01 Tasks 1, 3; 10.1-02 Tasks 1, 2 |
| CONTEXT | D-10 html/template, curated view model, escaping on | COVERED | 10.1-01 Task 3; 10.1-03 Task 1 |
| CONTEXT | D-11 headerPartial in config_list.yaml, missing template fails boot | COVERED | 10.1-01 Task 3; 10.1-03 Task 1 |
| CONTEXT | D-12 registered toolbar actions, create/delete unchanged | COVERED | 10.1-01 Task 2; 10.1-02 Task 3; 10.1-03 Task 3 |
| CONTEXT | D-13 Go method for JS/CSS, new interface name | COVERED | 10.1-01 Tasks 1, 2; 10.1-03 Task 2 |
| CONTEXT | D-14 assets load when the controller opens | COVERED | 10.1-02 Tasks 1, 3 |
| CONTEXT | D-15 embed.FS only, no disk switch | COVERED | 10.1-01 Task 2 |
| CONTEXT | D-16 same-origin under the prefix, CSP and cookie unchanged | COVERED | 10.1-01 Task 2; 10.1-02 Task 1 |
| CONTEXT | D-17 partial host without a raw-HTML sink | COVERED | 10.1-01 Task 3; 10.1-02 Task 2; 10.1-04 hygiene_101 |
| RESEARCH | Patterns 1-7 (contracts, YAML, action routes, node tree, assets, loader, OpenAPI) | COVERED | 10.1-01, 10.1-02 |
| RESEARCH | Pitfalls 1-14 | COVERED | 10.1-01 (1, 2, 5, 9, 10, 13, 14), 10.1-02 (4, 6, 7, 8), 10.1-03 (3, 11, 12, 14) |
| RESEARCH | Validation Architecture test map and Wave 0 gaps | COVERED | 10.1-04 Tasks 1-3 |
| RESEARCH | Security Domain T-10.1-01..13 and SC (split into T-10.1-14..22 by component) | COVERED | threat registers of all four plans; 10.1-04 review |
| RESEARCH | Package legitimacy audit (x/net promotion, no npm) | COVERED | 10.1-01 Task 3 and T-10.1-SC |
| RESEARCH | A4 browser CSP and module-loading check | COVERED | 10.1-03 Task 3 human-check; 10.1-02 Task 3 human-check |
| UI-SPEC | UI Considerations: 20 covered rows, 3 backstops, 1 dismissed | COVERED | 10.1-01, 10.1-02 and 10.1-03 must_haves |
| CONTEXT | Deferred ideas (Discogs client, disk override, WASM, Ctrl+K, badge column, Playwright, user/media navigation, admin personal tokens) | EXCLUDED | No task implements a deferred item |
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Test and gate results → phase acceptance | Gate completeness decides whether the extension point can be declared done |
| Framework repo → application repo | summercms.go must stay free of application knowledge; application assets and templates must follow the plugin JS and partial conventions |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-10.1-19 | Repudiation | Phase 10.1 acceptance evidence | high | mitigate | check-phase10.1.sh refuses failed, skipped, zero-test, non-JSON and build-failed runs and missing required tests, OpenAPI and dist drift, hygiene and evidence gaps; --self-test proves each detector; the review names a failing-when-broken test and a removal check per high threat. |
| T-10.1-20 | Tampering | framework/app boundary and extension conventions | low | mitigate | --hygiene runs the Phase 10 rules plus refusals for HTML-string parsers in admin/src, network, cookie or storage access in application plugin asset JS, and script or event-handler markup in application partial templates, each proven by a --self-test plant. |
| T-10.1-SC | Tampering | npm and Go dependencies | high | mitigate | No package added in this plan (no coverage provider); `npm ci` against the committed lockfile; swag pinned at v1.16.6. |
</threat_model>
<verification>
`scripts/check-phase10.1.sh --all` is the phase acceptance command, and `scripts/check-phase10.sh --all` must stay green. The manual-only checks (real-browser CSP and module loading, the 768px layout backstops, the Vite dev proxy) are the human-check blocks in 10.1-02 Task 3 and 10.1-03 Task 3, collected at /gsd-verify-work.
</verification>
<success_criteria>
- Every Phase 10.1 Go and SPA change has named tests; both repositories are green.
- TestPhase101AlbumsExtension proves the Albums strip, widget and toolbar action with scoping and permissions on real PostgreSQL.
- scripts/check-phase10.1.sh --all and scripts/check-phase10.sh --all pass; the security review and validation map are complete and truthful.
- The multi-source audit has no missing GOAL, REQ, RESEARCH or CONTEXT item and no deferred item in scope.
</success_criteria>
<output>
Create `.planning/phases/10.1-runtime-admin-extension-point/10.1-04-SUMMARY.md` when done.
</output>