Files
summercms/.planning/quick/261006-eyj-widget-field-payload-and-data-channel/261006-eyj-PLAN.md

38 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, estimate, must_haves
phase plan type wave depends_on files_modified autonomous requirements estimate must_haves
quick-261006-eyj 01 execute 1
modules/pact/capabilities.go
modules/pact/README.md
modules/cabana/actions.go
modules/cabana/admin_openapi.go
modules/cabana/README.md
modules/cabana/phase101_actions_test.go
modules/cabana/widget_payload_test.go
modules/cabana/testdata/extension/assets/js/lookup.js
.swaggo
admin/openapi/admin.json
admin/src/api/schema.d.ts
admin/src/components/form/formContext.ts
admin/src/components/form/fields/WidgetField.vue
admin/tests/form/WidgetField.test.ts
modules/boardwalk/dist/
docs/backend/partials-and-widgets.md
docs/backend/admin-spa.md
true
QUICK-261006-eyj
tokens raw_tokens tasks confidence
110000 110000 3 low
truths artifacts key_links
A widget custom element can dispatch summer-action with detail.payload (any JSON value); the SPA posts it as the body key payload next to the unchanged record_id and the still-filtered fill snapshot values, and the registered action receives it byte-for-byte as pact.AdminActionInput.Payload (nil when the request carried none)
A payload above 64 KiB is a 422 validation_failed on body and the action does not run; toolbar and record action routes answer 422 to any payload exactly as they do to values; CSRF, controller and action permissions, FormExtendQuery record scoping, the fill allowlist and the scalars-only fill write-back are unchanged (TestPhase101Actions and TestPhase10OpenAPIConformance stay green)
An action may return pact.AdminActionResult.Data: the response carries it as data exactly as encoded, not subject to the fill allowlist, omitted when nil; data whose JSON exceeds 256 KiB or cannot be encoded is an opaque 500 logged on the server
After each successful action WidgetField sets the serialized data on the element as the data attribute (removing it when the response carried none) and dispatches summer-result on the element with detail {data, fill, message}; fill write-back and toast behaviour are unchanged and an empty message shows no toast; nothing is set at mount, so the element still gets exactly busy-label, field-name, fill-values, label, locale and record-id
admin/openapi/admin.json and admin/src/api/schema.d.ts are regenerated by scripts/check-admin-openapi.sh with a root .swaggo override so cabana.AdminActionRequest keeps record_id and values and gains payload?: unknown, and cabana.AdminActionResult gains data?: unknown; scripts/check-admin-openapi.sh --check and scripts/check-admin-dist.sh pass
docs/backend/partials-and-widgets.md, the cabana README and the pact README describe the payload and data channel and the element events; go test ./cmd/summer -run TestDocsTree passes; the testdata lookup.js widget sends a payload and renders returned data without HTTP or innerHTML
path provides contains
modules/pact/capabilities.go AdminActionInput.Payload json.RawMessage and AdminActionResult.Data any Payload json.RawMessage
path provides contains
modules/cabana/actions.go payload decode cap, payload pass-through, toolbar/record refusal, data pass-through with 256 KiB cap maxActionPayloadBytes
path provides contains
modules/cabana/admin_openapi.go AdminActionRequest.Payload and AdminActionResult.Data with swag annotations json:"payload,omitempty"
path provides contains
.swaggo swag type override so json.RawMessage documents as any replace json.RawMessage any
path provides contains
modules/cabana/widget_payload_test.go handler tests for payload accepted, refused, capped, and data untouched while fill filtered func TestWidgetPayloadAndData
path provides contains
admin/src/components/form/fields/WidgetField.vue detail.payload posting, data attribute, summer-result dispatch WIDGET_RESULT_EVENT
path provides contains
admin/tests/form/WidgetField.test.ts SPA unit tests for payload posting and data attribute/event summer-result
path provides contains
modules/cabana/testdata/extension/assets/js/lookup.js fixture widget that sends a payload and renders data payload
path provides contains
docs/backend/partials-and-widgets.md Form widgets docs: payload and data channel, element contract summer-result
from to via pattern
summer-action CustomEvent detail.payload on the plugin element POST .../widgets/{field} body key payload WidgetField.vue listener reads event.detail.payload and includes payload only when present payload
from to via pattern
cabana.AdminActionRequest.Payload pact.AdminActionInput.Payload widgetAction assigns in.Payload after decodeActionRequest's 64 KiB check input.Payload = in.Payload
from to via pattern
pact.AdminActionResult.Data AdminActionResult.Data as json.RawMessage in the envelope runAction marshals once, checks 256 KiB, embeds raw bytes; fill still goes through onlyFillScalars maxActionDataBytes
from to via pattern
response data element data attribute and summer-result event WidgetField.vue after patch(fill) and before the toast setAttribute('data'
from to via pattern
modules/cabana/admin_openapi.go annotations admin/src/api/schema.d.ts scripts/check-admin-openapi.sh (swag v1.16.6 with .swaggo -> internal/tools/swagger2openapi -> openapi-typescript); no server needed payload?: unknown
Let a `type: widget` admin form field send its own payload to its controller action and receive structured data back, so a plugin can ship an interactive widget (a drag-to-reorder list of child records, for example) instead of a single button. Today the element only gets attributes, signals with a bare `summer-action` event, the SPA posts `{record_id, values}`, and the answer is `{message, fill}` with `fill` restricted to scalar fill keys. This change adds an optional request `payload` (any JSON, 64 KiB cap) that reaches the action untouched as `pact.AdminActionInput.Payload`, and an optional response `data` (any JSON, 256 KiB cap, not subject to the fill allowlist) from `pact.AdminActionResult.Data` that the SPA writes onto the element as a `data` attribute and announces with a `summer-result` event. Every existing guarantee stays: CSRF header, controller plus action permissions, FormExtendQuery record scope, fill allowlist, scalars-only fill write-back, strict body decoding, toolbar and record actions refusing anything but `{}`.

Non-goals: no change to the fill contract, no widget-initiated HTTP (the SPA still owns the request), no new permissions model, no drag-and-drop component in the SPA itself (plugins ship their own element).

Purpose: the widget extension point is the only way a compiled plugin adds interactive admin UI without a Node build; without a data channel it cannot build anything richer than a button. Output: pact and cabana contract changes with handler tests, regenerated OpenAPI document and TypeScript types, WidgetField.vue with unit tests and a rebuilt modules/boardwalk/dist, docs and READMEs, and an extended testdata widget. Three code commits, one per task, no .planning files in them, no co-author trailers.

<execution_context> @/.claude/gsd-core/workflows/execute-plan.md @/.claude/gsd-core/templates/summary.md </execution_context>

@.planning/STATE.md @CLAUDE.md @modules/cabana/actions.go @modules/cabana/admin_openapi.go @modules/pact/capabilities.go @modules/cabana/phase101_actions_test.go @modules/cabana/phase121_actions_test.go @modules/cabana/README.md @modules/pact/README.md @admin/src/components/form/fields/WidgetField.vue @admin/src/components/form/formContext.ts @admin/tests/form/WidgetField.test.ts @admin/tests/smoke/extension.smoke.test.ts @scripts/check-admin-openapi.sh @scripts/check-admin-dist.sh @docs/backend/partials-and-widgets.md @modules/cabana/testdata/extension/assets/js/lookup.js

Facts established while planning (do not re-derive):

  • swag v1.16.6 without --parseDependency cannot resolve json.RawMessage. Verified empirically: a struct with a json.RawMessage field is emitted as a bare {"type":"object"} with ALL of its properties dropped, so record_id and values would vanish from the TypeScript type too. The fix is a type override file .swaggo at the repo root (swag's default --overridesFile, read relative to the cwd; scripts/check-admin-openapi.sh does cd "$ROOT" first) containing replace json.RawMessage any. With it the field becomes an untyped {} schema, internal/tools/swagger2openapi passes it through, and openapi-typescript 7.13 renders payload?: unknown. A Go any field (Data any) already renders as {} -> data?: unknown with no override.
  • scripts/check-admin-openapi.sh is how admin/openapi/admin.json and admin/src/api/schema.d.ts are regenerated: go run github.com/swaggo/swag/cmd/swag@v1.16.6 init --dir modules/cabana --generalInfo admin_openapi.go --requiredByDefault -> go run ./internal/tools/swagger2openapi -> admin/node_modules/.bin/openapi-typescript. It needs the Go toolchain and admin/node_modules (it runs npm --prefix admin ci when missing); it does NOT need a running server. --check diffs against the committed files. npm --prefix admin run gen:api only redoes the last step from the committed admin.json and is not sufficient here.
  • The embedded SPA build modules/boardwalk/dist is committed; admin/vite.config.ts has outDir: '../modules/boardwalk/dist'. After any SPA change run npm --prefix admin run build (vue-tsc then vite build) and commit the dist tree; scripts/check-admin-dist.sh is the drift gate.
  • cabana database-backed tests use testcontainers PostgreSQL via adminGorm (skips under -short). newActEnv (phase101_actions_test.go) boots the acme.demo fixture with the lookup widget action, recount toolbar action and an actSpy that records every pact.AdminActionInput; newRosterEnv (phase121_fixture_test.go) boots acme.roster whose people form declares record actions (activate, reinstate) reachable at rosterPeople + "/{id}/actions/{name}"; rosterInsert, rosterLoad, actResult, actErrorCode are reusable helpers.
  • Existing assertions that must keep passing untouched: TestPhase101Actions toolbar subtest compares calls[0] to a zero pact.AdminActionInput{} (so Payload must be nil when absent); the "widget with an in-scope record" subtest posts without payload; admin/tests/form/WidgetField.test.ts and admin/tests/smoke/extension.smoke.test.ts assert the element's attribute list at mount is exactly busy-label, field-name, fill-values, label, locale, record-id and that a payload-less post body equals { record_id, values } (no payload key).
  • TestPhase10OpenAPIConformance decodes action responses into cabana.AdminActionResult with DisallowUnknownFields and checks the $ref names in admin/openapi/admin.json; adding data,omitempty keeps it green as long as admin.json is regenerated.
  • The docs identifier checker (internal/docsite/check_identifiers.go, run by go test ./cmd/summer -run TestDocsTree) verifies backticked spans shaped pkg.Identifier. Name types as pact.AdminActionInput / pact.AdminActionResult / cabana.AdminActionRequest and refer to Payload and Data as plain field names in prose.
  • Commit rules: one logical change per commit, planning docs and code never in the same commit, no co-author or session trailers (user CLAUDE.md overrides any attribution reminder).
Task 1: Server contract end to end — payload in, data out, OpenAPI and types regenerated, Go tests modules/pact/capabilities.go, modules/pact/README.md, modules/cabana/actions.go, modules/cabana/admin_openapi.go, modules/cabana/README.md, modules/cabana/phase101_actions_test.go, modules/cabana/widget_payload_test.go, .swaggo, admin/openapi/admin.json, admin/src/api/schema.d.ts, docs/backend/partials-and-widgets.md, docs/backend/admin-spa.md modules/cabana/actions.go (widgetAction, toolbarAction, recordAction, runAction, decodeActionRequest, onlyFillScalars), modules/cabana/admin_openapi.go lines 440-505, modules/pact/capabilities.go lines 215-250, modules/cabana/phase101_actions_test.go (actPlugin fixture, lookup action switch, newActEnv, actResult, actErrorCode), modules/cabana/phase121_actions_test.go TestRecordActionSmoke (rosterPeople, rosterInsert, rosterLoad), scripts/check-admin-openapi.sh, modules/cabana/README.md bullet "Form widgets and controller actions" plus the route table rows for widgets/toolbar/record actions and the API reference rows for cabana.AdminActionRequest and cabana.AdminActionResult, modules/pact/README.md rows for pact.AdminActionInput and pact.AdminActionResult, docs/backend/partials-and-widgets.md "Form widgets" section Wire the whole server path for one widget request: pact contract, cabana decode, action call, response envelope, OpenAPI document, generated TypeScript types, module docs, and the tests that prove it. Keep every existing guarantee; this is additive.

pact (modules/pact/capabilities.go): import encoding/json. Add Payload json.RawMessage to AdminActionInput after Values, documented as the widget's own payload (the summer-action event's detail.payload, posted as the body key payload): any JSON value up to 64 KiB, exactly the bytes the client sent, nil when the request carried none and always nil for a toolbar action; the framework neither inspects nor filters it and an action decodes it itself with encoding/json and validates ids it contains against its own scope. Add Data any to AdminActionResult after Fill, documented as structured data for the widget: written to the response as data exactly as it encodes (any JSON value, capped at 256 KiB encoded; larger or unencodable is a 500), not subject to the fill allowlist, omitted when nil. Update the AdminActionInput/AdminActionResult doc comments accordingly. Update modules/pact/README.md: the pact.AdminActionInput row mentions the raw widget payload, the pact.AdminActionResult row mentions the optional data value next to message and fill.

cabana wire types (modules/cabana/admin_openapi.go): import encoding/json. Add Payload json.RawMessage with tag json:"payload,omitempty" to AdminActionRequest and Data any with tag json:"data,omitempty" to AdminActionResult, each with a doc comment (payload: the widget's own JSON value, at most 64 KiB, refused on toolbar and record routes; data: the action's structured answer, any JSON, not filtered by fill, absent when the action returned none). Update the @Description of AdminWidgetAction (payload accepted and handed to the action as-is; response may carry data), AdminToolbarAction and AdminRecordAction (body must be {}; record_id, values and payload are all refused), and the @Param body description of the widget route ("Record id, fill snapshot and optional payload").

swag override (new file .swaggo at the repository root): a // comment explaining that swag v1.16.6 cannot resolve encoding/json.RawMessage without this and silently drops every property of a struct that uses it, then the single directive line replace json.RawMessage any. Mention the file in docs/backend/admin-spa.md in the paragraph that describes scripts/check-admin-openapi.sh (one sentence: type overrides for swag live in the root .swaggo).

cabana handlers (modules/cabana/actions.go): add package constants maxActionPayloadBytes = 64 << 10 and maxActionDataBytes = 256 << 10 with comments. In decodeActionRequest, after the trailing-token check, return a *ValidationError with details body: ["The payload may not exceed 64 KiB."] when len(in.Payload) > maxActionPayloadBytes; update its doc comment to name the three accepted keys. In widgetAction, set input.Payload = in.Payload when building pact.AdminActionInput (no copy, no inspection). In toolbarAction and recordAction, extend the refusal condition with len(in.Payload) > 0 and change the messages to "A toolbar action takes no record_id, values or payload." and "A record action takes no record_id, values or payload." (nothing asserts the old text). In runAction, build the AdminActionResult with the translated message and onlyFillScalars(fill, result.Fill) as today; when result.Data != nil, json.Marshal it once and, if that fails or the encoded length exceeds maxActionDataBytes, log with slog.Error ("cabana: admin action data rejected" with controller, action, field, byte count and error) and answer the generic 500 body without echoing anything; otherwise set the result's Data to json.RawMessage(raw) so the envelope embeds the bytes without a second encoding. Update the widgetAction and runAction doc comments (payload passes through; data bypasses the fill filter but not the size cap). CRUDService.RecordAction in crud.go stays unchanged: it never sets Data, so the key is omitted there.

Fixture change (modules/cabana/phase101_actions_test.go, the lookup action's Run): before the existing switch in.Values["name"], when len(in.Payload) > 0 decode the payload into any (return the decode error as-is so a malformed payload is a 500), then: payload equal to the string "big" returns Data: strings.Repeat("x", 256<<10+1); payload equal to the string "nan" returns Data: math.NaN(); any other payload returns Message "acme.demo::lang.gadgets.looked_up", Fill of name "reordered", tenant "other" and active as a string slice (to prove fill is still filtered), and Data as a map with key echo holding the decoded payload and key items holding a slice of one map {id: 1, title: "a"}. Leave the existing switch and every other case untouched.

Tests (new file modules/cabana/widget_payload_test.go, package cabana_test, one TestWidgetPayloadAndData using newActEnv, actInsert, actResult, actErrorCode, env.spy.take() and the widget path "/acme/demo/gadgets/widgets/lookup", plus a roster subtest using newRosterEnv). Subtests: (a) payload reaches the action and data comes back untouched: POST with record_id of an acme gadget, values {name "typed", tenant "other"} and payload {order [3,1,2], note "x"} is 200; the spy's single call has string(in.Payload) equal to the exact payload literal and Values equal to {name "typed"}; the decoded result.Fill is exactly {name "reordered"}; result.Data deep-equals the map {echo: {order: [3,1,2], note "x"}, items: [{id 1, title "a"}]} using float64 numbers. (b) payload may be any JSON value and is nil when absent: bodies with payload [1,2], "s", 0, false and null are each 200 and the spy call's Payload bytes equal the literal; a {} body gives a call whose Payload has length 0 and is nil. (c) no data key when the action returns none: decode the raw body of a payload-less widget call and of a toolbar call into map[string]any and assert the inner data object has no "data" key. (d) payload cap: a body whose payload is a JSON string of 65534 "a" characters (a 65536-byte raw value) is 200; one of 65535 characters (65537 bytes) is 422 validation_failed with a body detail, and the spy saw no call for it. (e) toolbar and record routes refuse a payload: toolbar bodies {"payload":{}}, {"payload":null}, {"payload":1} are each 422 with no spy call; with newRosterEnv, insert an inactive acme person and POST rosterPeople/{id}/actions/activate with {"payload":1}: 422 and rosterLoad shows Active still false. (f) data over 256 KiB or unencodable is an opaque 500: payload "big" and payload "nan" each answer 500 with error code "error" and the body does not contain "xxxx".

Regenerate the OpenAPI artefacts by running scripts/check-admin-openapi.sh (no arguments). Confirm in admin/src/api/schema.d.ts that "cabana.AdminActionRequest" now has payload?: unknown AND still has record_id?: number and values?:, and that "cabana.AdminActionResult" has data?: unknown, fill and message. If record_id/values are missing, the .swaggo override was not picked up (check the cwd and file name) — do not hand-edit the generated files.

Docs for the Go contract (same change as the exported API per CLAUDE.md): in modules/cabana/README.md extend the "Form widgets and controller actions" Features bullet with the payload and data channel (payload key, 64 KiB cap, refused on toolbar and record routes, handed to the action unfiltered; data key, 256 KiB cap, not subject to the fill allowlist, omitted when nil), update the widgets route-table row to answer {message, fill, data?} with an optional payload in the request, and update the API reference rows for cabana.AdminActionRequest (optional payload, 64 KiB) and cabana.AdminActionResult (optional data as returned). In docs/backend/partials-and-widgets.md "Form widgets", extend the paragraph after the bullet list: the SPA posts record_id, the fill snapshot values and, when the element supplied one, payload; the action reads it from the Payload field of pact.AdminActionInput as raw JSON and must treat any ids in it as untrusted input to re-check against its own scope; it may answer with Data on pact.AdminActionResult, any JSON value up to 256 KiB that is passed through as data and is not filtered like fill; toolbar and record actions refuse a payload. Do not document the SPA-side element events yet (Task 3 does).

Commit: one commit with everything above (code, tests, generated files, .swaggo, READMEs, docs), message in the repo's style such as "feat(cabana): widget action payload and data channel". No .planning files, no trailers. cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && go vet ./... && go test ./modules/pact/... && go test ./modules/cabana/ -run 'TestWidgetPayloadAndData|TestPhase101Actions|TestPhase10OpenAPIConformance|TestRecordActionSmoke' -count=1 && scripts/check-admin-openapi.sh --check && go test ./cmd/summer -run TestDocsTree && grep -c 'payload?: unknown' admin/src/api/schema.d.ts && grep -A4 '"cabana.AdminActionRequest": {' admin/src/api/schema.d.ts | grep -c 'record_id?: number' pact.AdminActionInput has Payload json.RawMessage and pact.AdminActionResult has Data any; cabana decodes payload (strict keys, 64 KiB cap -> 422 on body), hands it to the widget action untouched, refuses it on toolbar and record routes, and writes Data as-is (256 KiB cap or unencodable -> opaque 500) while fill is still filtered; .swaggo exists; admin.json and schema.d.ts are regenerated (payload?: unknown with record_id and values intact; data?: unknown) and --check passes; cabana and pact READMEs and docs/backend/partials-and-widgets.md describe the Go contract; TestWidgetPayloadAndData, TestPhase101Actions, TestPhase10OpenAPIConformance, TestRecordActionSmoke and TestDocsTree pass; one code commit.

Task 2: WidgetField.vue posts detail.payload, writes the data attribute and dispatches summer-result; SPA tests; dist rebuild admin/src/components/form/formContext.ts, admin/src/components/form/fields/WidgetField.vue, admin/tests/form/WidgetField.test.ts, modules/boardwalk/dist/ admin/src/components/form/fields/WidgetField.vue (onAction, listener, onMounted), admin/src/components/form/formContext.ts (WIDGET_EVENT, WIDGET_TIMEOUT), admin/tests/form/WidgetField.test.ts (mountWidget, element, act, deferred helpers and the "summer-action" describe block), admin/tests/helpers.ts (Reply, Route, mockApi, requestsTo), admin/tests/smoke/extension.smoke.test.ts widget describe block, admin/src/api/schema.d.ts entries for cabana.AdminActionRequest and cabana.AdminActionResult as regenerated by Task 1 - Dispatching summer-action with detail { payload: { order: [3, 1, 2] } } posts a body of exactly { record_id: 7, values: { name, color }, payload: { order: [3, 1, 2] } } with the X-Requested-With header; detail payloads [1, 2], 0, false and null are sent as those JSON values. - Dispatching summer-action with no detail, or with a detail object that has no payload property, posts a body with no payload key at all (the existing body assertions keep passing). - A 200 reply { data: { message: 'Reordered', fill: { name: 'New', weight: 9 }, data: { items: [{ id: 1 }, { id: 2 }] } }, meta: {} } patches only name, toasts 'Reordered', sets the element attribute data to the string '{"items":[{"id":1},{"id":2}]}', and dispatches one summer-result event on the element whose detail deep-equals { data: { items: [...] }, fill: { name: 'New', weight: 9 }, message: 'Reordered' }; patch is never called with data. - A following 200 reply without a data key removes the data attribute and dispatches summer-result with detail.data undefined, detail.fill and detail.message from the reply; an empty message shows no toast. - A failed action (422, 500, network error) dispatches no summer-result, sets no data attribute, and still toasts danger and sets state=error as today. - At mount the element's attribute names are still exactly busy-label, field-name, fill-values, label, locale, record-id (no data attribute until a successful action). Write the failing tests first in admin/tests/form/WidgetField.test.ts, then implement.

formContext.ts: export WIDGET_RESULT_EVENT = 'summer-result' next to WIDGET_EVENT, with a comment that the SPA dispatches it on the plugin element after each successful action with detail {data, fill, message}.

WidgetField.vue: change listener to take the Event, read (event as CustomEvent<unknown>).detail, and derive payload as the detail's own payload property when the detail is a non-null object that has one (Object.hasOwn), otherwise undefined; pass it to onAction(payload: unknown). In onAction, build the body as { record_id: props.recordId ?? undefined, values: fillValues() } and add the payload key only when payload is not undefined (spread a conditional object; the generated type payload?: unknown accepts any value). Capture the element in a local before awaiting. On success, after the existing fill patch loop and before the toast: read the result's data property; if the response object has an own data key, setAttribute('data', JSON.stringify(value)), otherwise removeAttribute('data'); then dispatch new CustomEvent(WIDGET_RESULT_EVENT, { detail: { data, fill, message } }) on the element (non-bubbling is fine; the plugin element listens on itself), where fill is the response's fill object as returned and message the response message; skip the attribute and event when the component was unmounted meanwhile. Keep the toast logic (empty message -> no toast) and the failure path (toast danger, state=error) unchanged; the failure path must not touch the data attribute or dispatch summer-result. Update the header comment of the component to describe the payload and data channel (attributes plus the data attribute after a result; summer-action with optional detail.payload; summer-result with detail {data, fill, message}; still no token, cookie, Vue instance or function on the element).

Tests: extend the act helper with an optional detail argument passed into the CustomEvent init. Add a describe block "payload and data (summer-result)" covering every line of the behavior list above: capture summer-result events with a listener added to the element before acting, assert the attribute string and the event detail, assert Object.hasOwn(parsedBody, 'payload') is false for payload-less events and for a detail without a payload property, and assert the attribute is removed and detail.data is undefined on a reply without data. Keep the existing "sets attributes only" assertion untouched.

Rebuild the embedded SPA: npm --prefix admin run build (runs vue-tsc and writes modules/boardwalk/dist via vite's outDir) and include the resulting dist tree in the commit.

Commit: one commit with formContext.ts, WidgetField.vue, WidgetField.test.ts and modules/boardwalk/dist, e.g. "feat(admin): widget field payload and summer-result data channel". No .planning files, no trailers. cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && npm --prefix admin test -- tests/form/WidgetField.test.ts tests/smoke/extension.smoke.test.ts tests/form/FormView.test.ts && npm --prefix admin run typecheck && scripts/check-admin-dist.sh && go test ./modules/boardwalk/... && test -z "$(git status --porcelain -- modules/boardwalk/dist admin)" WidgetField posts detail.payload as payload only when the event carried one, sets or removes the element's data attribute from the response data, dispatches summer-result with {data, fill, message} on success only, and leaves fill write-back, toasts, failure handling and the mount-time attribute list unchanged; the new unit tests and the existing WidgetField, FormView and extension smoke tests pass; vue-tsc typecheck passes; modules/boardwalk/dist matches a fresh build; one code commit.

Task 3: Fixture widget that sends a payload and renders data; element-contract docs modules/cabana/testdata/extension/assets/js/lookup.js, docs/backend/partials-and-widgets.md, modules/cabana/README.md modules/cabana/testdata/extension/assets/js/lookup.js, modules/cabana/phase101_assets_test.go (how the fixture file is served and asserted), docs/backend/partials-and-widgets.md "Form widgets" section as left by Task 1, modules/cabana/README.md "Form widgets and controller actions" bullet as left by Task 1, admin/src/components/form/fields/WidgetField.vue as left by Task 2 Fixture widget (modules/cabana/testdata/extension/assets/js/lookup.js): keep the class name AcmeDemoLookup, the tag acme-demo-lookup, the plain custom element style, and the rule that it makes no request and reads no cookie or storage. Extend it so it demonstrates both directions: it renders the one button (text from the label attribute) plus an ordered list element; it keeps an in-memory array of items (objects with id and title) that starts empty and is replaced from the `data` attribute (parse JSON, read its items array) in `attributeChangedCallback` with `static get observedAttributes()` returning data, and also from a `summer-result` listener on itself reading event.detail.data; rendering uses textContent only, never innerHTML. The button click reverses the current item order (the simplest stand-in for a drag reorder) and dispatches summer-action with bubbles, composed and `detail: { payload: { order: } }`. Keep it under roughly 50 lines, no modules, no dependencies. Update the header comment to say the SPA owns HTTP and this element only speaks the summer-action / summer-result events.

Docs (docs/backend/partials-and-widgets.md, inside "Form widgets" after the paragraph Task 1 extended): add a subsection "Widget element contract" that lists what the SPA sets on the element (attributes only: record-id, field-name, locale, fill-values as JSON, label, busy-label; busy while a request runs; state set to error after a failure; data holding the serialized response data after a successful action, removed when the answer carried none) and the two events: summer-action, dispatched by the element with bubbles and composed, whose optional detail.payload is any JSON value the SPA posts as payload; summer-result, dispatched by the SPA on the element after a successful action with detail {data, fill, message}. State explicitly that the element never receives a token, cookie, Vue instance or function, must not make its own requests, and should render server data with textContent, not innerHTML. Point at the acme-demo-lookup fixture under modules/cabana/testdata/extension/assets/js/lookup.js as a complete example of a reorder widget (send the new id order, render the returned list).

README (modules/cabana/README.md, "Form widgets and controller actions" bullet): add one sentence that the element asks for its action with a summer-action event whose optional detail.payload becomes the request payload, and receives the answer as a data attribute plus a summer-result event with {data, fill, message}.

Commit: one commit for lookup.js, the docs page and the README sentence, e.g. "docs(cabana): widget element contract and payload fixture". No .planning files, no trailers. Then run the whole gate once more (go vet, full go test, docs tree) so HEAD is green. cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && node --check modules/cabana/testdata/extension/assets/js/lookup.js && grep -c "summer-result" modules/cabana/testdata/extension/assets/js/lookup.js docs/backend/partials-and-widgets.md modules/cabana/README.md && ! grep -q "innerHTML" modules/cabana/testdata/extension/assets/js/lookup.js && go vet ./... && go test ./modules/cabana/... -count=1 && go test ./cmd/summer -run TestDocsTree && scripts/check-admin-openapi.sh --check && test -z "$(git status --porcelain -- modules admin docs cmd internal scripts .swaggo)" lookup.js is a working reorder example: it sends summer-action with detail.payload.order and renders data from the data attribute and the summer-result event using textContent, with no HTTP; the docs page has a "Widget element contract" subsection and the cabana README bullet names both events; TestDocsTree, the cabana suite (including the asset tests that serve lookup.js) and the OpenAPI drift check pass; the working tree holds no uncommitted code changes; one code commit.

<threat_model>

Trust Boundaries

Boundary Description
plugin element -> SPA A plugin's custom element hands the SPA an arbitrary detail.payload; the SPA forwards it without interpreting it
SPA -> cabana action routes Untrusted JSON body (record_id, values, payload) crosses into the framework; CSRF header, JWT, permissions and record scope are checked there
cabana -> plugin action Payload bytes reach plugin code unfiltered; the plugin owns their validation
plugin action -> SPA -> element Data bypasses the fill allowlist on purpose and lands on the element as an attribute and event detail

STRIDE Threat Register

Threat ID Category Component Severity Disposition Mitigation Plan
T-Q261006-01 Denial of service decodeActionRequest payload medium mitigate 64 KiB payload cap enforced after strict decode, 422 on body, action never runs (Task 1 test d); the surf body limit still bounds the whole request
T-Q261006-02 Denial of service runAction data encoding medium mitigate Data encoded once, rejected above 256 KiB or when unencodable with an opaque 500 and a server log (Task 1 test f)
T-Q261006-03 Elevation of privilege widgetAction record loading high mitigate Payload never selects the record: record_id is still loaded through FormExtendQuery by readScopedRecord; existing out-of-scope 404 tests stay green; docs tell actions to treat ids inside payload as untrusted and re-scope them
T-Q261006-04 Tampering toolbarAction, recordAction medium mitigate Any payload on toolbar and record routes is a 422 before the action runs (Task 1 test e), keeping those routes body-free as designed
T-Q261006-05 Information disclosure AdminActionResult.Data medium accept Data intentionally bypasses the fill allowlist because it is the plugin's own answer to its own widget; the action already runs under controller and action permissions and the README/docs state that data is unfiltered and must not carry secrets
T-Q261006-06 Tampering (XSS) plugin element rendering data medium mitigate The SPA only sets an attribute string and dispatches an event, never injects HTML; docs require textContent rendering and the fixture widget uses textContent only (Task 3 verify forbids innerHTML in the fixture)
T-Q261006-07 Repudiation runAction low accept Existing action logging is unchanged; payload contents are not logged by design to avoid leaking plugin data into logs
T-Q261006-SC Tampering npm/go installs low accept No new dependency is added by this change; swag is still invoked at the pinned v1.16.6 via go run and openapi-typescript at the locked admin devDependency version
</threat_model>
- `go vet ./...` and `go test ./...` green at HEAD after each task's commit (cabana tests need Docker for testcontainers PostgreSQL; `-short` skips them and is not a substitute for the gate). - `scripts/check-admin-openapi.sh --check` passes: admin.json and schema.d.ts match the annotations, with payload?: unknown and data?: unknown present and record_id/values intact. - `scripts/check-admin-dist.sh` passes: modules/boardwalk/dist matches a fresh build of the changed SPA. - `npm --prefix admin test` and `npm --prefix admin run typecheck` pass. - `go test ./cmd/summer -run TestDocsTree` passes after the README and docs edits. - Three code commits (Go contract + generated types + docs, SPA + dist, fixture + element docs), none containing .planning files, none with co-author or session trailers.

<success_criteria>

  • A widget element can send detail.payload on summer-action and the registered action receives it as raw JSON in pact.AdminActionInput; toolbar and record actions refuse it; oversized payload is a 422.
  • An action can return Data; it reaches the element as a data attribute and a summer-result event untouched, while fill is still filtered to declared scalar keys; oversized or unencodable data is an opaque 500.
  • Every pre-existing widget, toolbar, record action, conformance and SPA test still passes unchanged.
  • OpenAPI document, TypeScript types, embedded SPA build, module READMEs and docs are regenerated or updated in the same change set, and the fixture widget demonstrates the full round trip. </success_criteria>
Create `.planning/quick/261006-eyj-widget-field-payload-and-data-channel/261006-eyj-SUMMARY.md` when done