docs(quick-261006-eyj): widget field payload and data channel

This commit is contained in:
Jakub Zych
2026-10-06 11:23:37 +02:00
parent f829d17dca
commit e88007aa76
3 changed files with 419 additions and 1 deletions

View File

@@ -0,0 +1,271 @@
---
phase: quick-261006-eyj
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- 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
autonomous: true
requirements: [QUICK-261006-eyj]
estimate:
tokens: 110000
raw_tokens: 110000
tasks: 3
confidence: low
must_haves:
truths:
- "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"
artifacts:
- path: "modules/pact/capabilities.go"
provides: "AdminActionInput.Payload json.RawMessage and AdminActionResult.Data any"
contains: "Payload json.RawMessage"
- path: "modules/cabana/actions.go"
provides: "payload decode cap, payload pass-through, toolbar/record refusal, data pass-through with 256 KiB cap"
contains: "maxActionPayloadBytes"
- path: "modules/cabana/admin_openapi.go"
provides: "AdminActionRequest.Payload and AdminActionResult.Data with swag annotations"
contains: "json:\"payload,omitempty\""
- path: ".swaggo"
provides: "swag type override so json.RawMessage documents as any"
contains: "replace json.RawMessage any"
- path: "modules/cabana/widget_payload_test.go"
provides: "handler tests for payload accepted, refused, capped, and data untouched while fill filtered"
contains: "func TestWidgetPayloadAndData"
- path: "admin/src/components/form/fields/WidgetField.vue"
provides: "detail.payload posting, data attribute, summer-result dispatch"
contains: "WIDGET_RESULT_EVENT"
- path: "admin/tests/form/WidgetField.test.ts"
provides: "SPA unit tests for payload posting and data attribute/event"
contains: "summer-result"
- path: "modules/cabana/testdata/extension/assets/js/lookup.js"
provides: "fixture widget that sends a payload and renders data"
contains: "payload"
- path: "docs/backend/partials-and-widgets.md"
provides: "Form widgets docs: payload and data channel, element contract"
contains: "summer-result"
key_links:
- from: "summer-action CustomEvent detail.payload on the plugin element"
to: "POST .../widgets/{field} body key payload"
via: "WidgetField.vue listener reads event.detail.payload and includes payload only when present"
pattern: "payload"
- from: "cabana.AdminActionRequest.Payload"
to: "pact.AdminActionInput.Payload"
via: "widgetAction assigns in.Payload after decodeActionRequest's 64 KiB check"
pattern: "input.Payload = in.Payload"
- from: "pact.AdminActionResult.Data"
to: "AdminActionResult.Data as json.RawMessage in the envelope"
via: "runAction marshals once, checks 256 KiB, embeds raw bytes; fill still goes through onlyFillScalars"
pattern: "maxActionDataBytes"
- from: "response data"
to: "element data attribute and summer-result event"
via: "WidgetField.vue after patch(fill) and before the toast"
pattern: "setAttribute\\('data'"
- from: "modules/cabana/admin_openapi.go annotations"
to: "admin/src/api/schema.d.ts"
via: "scripts/check-admin-openapi.sh (swag v1.16.6 with .swaggo -> internal/tools/swagger2openapi -> openapi-typescript); no server needed"
pattern: "payload\\?: unknown"
---
<objective>
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.
</objective>
<execution_context>
@~/.claude/gsd-core/workflows/execute-plan.md
@~/.claude/gsd-core/templates/summary.md
</execution_context>
<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).
</context>
<tasks>
<task type="tracer">
<name>Task 1: Server contract end to end — payload in, data out, OpenAPI and types regenerated, Go tests</name>
<files>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</files>
<read_first>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</read_first>
<action>
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.
</action>
<verify>
<automated>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'</automated>
</verify>
<done>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.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: WidgetField.vue posts detail.payload, writes the data attribute and dispatches summer-result; SPA tests; dist rebuild</name>
<files>admin/src/components/form/formContext.ts, admin/src/components/form/fields/WidgetField.vue, admin/tests/form/WidgetField.test.ts, modules/boardwalk/dist/</files>
<read_first>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</read_first>
<behavior>
- 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).
</behavior>
<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.
</action>
<verify>
<automated>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)"</automated>
</verify>
<done>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.</done>
</task>
<task type="auto">
<name>Task 3: Fixture widget that sends a payload and renders data; element-contract docs</name>
<files>modules/cabana/testdata/extension/assets/js/lookup.js, docs/backend/partials-and-widgets.md, modules/cabana/README.md</files>
<read_first>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</read_first>
<action>
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: <ids in the new 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.
</action>
<verify>
<automated>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)"</automated>
</verify>
<done>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.</done>
</task>
</tasks>
<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>
<verification>
- `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.
</verification>
<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>
<output>
Create `.planning/quick/261006-eyj-widget-field-payload-and-data-channel/261006-eyj-SUMMARY.md` when done
</output>

View File

@@ -0,0 +1,146 @@
---
phase: quick-261006-eyj
plan: 01
subsystem: admin-extension
tags: [cabana, pact, admin-spa, widgets, openapi]
status: complete
requires:
- Phase 10.1 widget action route and WidgetField.vue
provides:
- pact.AdminActionInput.Payload (json.RawMessage) and pact.AdminActionResult.Data (any)
- cabana widget route payload (64 KiB cap) and data (256 KiB cap) pass-through
- WidgetField.vue payload posting, data attribute and summer-result event
- root .swaggo type override for json.RawMessage
affects:
- modules/pact
- modules/cabana
- admin SPA (modules/boardwalk/dist)
tech-stack:
added: []
patterns:
- "swag v1.16.6 type override file .swaggo (replace json.RawMessage any) so a RawMessage field does not drop the struct's other properties"
- "Action Data encoded once in runAction and embedded as json.RawMessage so the envelope never double-encodes"
key-files:
created:
- .swaggo
- modules/cabana/widget_payload_test.go
modified:
- 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/testdata/extension/assets/js/lookup.js
- 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
decisions:
- "Payload is kept as json.RawMessage end to end (cabana.AdminActionRequest -> pact.AdminActionInput) so the action receives exactly the client's bytes; `null` is a 4-byte payload, not nil, so toolbar and record routes refuse {\"payload\":null} like any other payload"
- "Data bypasses the fill allowlist by design (T-Q261006-05 accepted) but is encoded once and capped at 256 KiB; larger or unencodable data is an opaque 500 with a server log, never echoed"
- "The SPA sets the data attribute and dispatches summer-result only on success and only while still mounted; the failure path is untouched"
metrics:
duration: 13 min
completed: 2026-10-06
actuals:
tokens: 15208
tokens_note: chars/4 over the realized diff excluding the rebuilt modules/boardwalk/dist bundle (287117 including it)
tasks: 3
commits: 3
plan_head_before: 964145628adeceb4dc93aae87001c3d900e69651
plan_head_after: e723c39
---
# Quick 261006-eyj: Widget field payload and data channel Summary
A `type: widget` admin field can now send its own JSON payload to its controller action (`summer-action` detail.payload -> body `payload` -> `pact.AdminActionInput.Payload`, 64 KiB cap) and receive structured data back (`pact.AdminActionResult.Data` -> response `data`, 256 KiB cap, not fill-filtered -> element `data` attribute plus a `summer-result` event), with every existing guarantee intact.
## What was built
**Task 1 (tracer, commit 6af88f9) - server contract end to end**
- `pact.AdminActionInput` gained `Payload json.RawMessage`; `pact.AdminActionResult` gained `Data any`, both documented in the type comments and the pact README.
- `cabana.decodeActionRequest` keeps strict decoding and adds a 64 KiB cap on `payload` (422 `validation_failed` on `body`, the action never runs). `widgetAction` assigns `input.Payload = in.Payload` without inspection. `toolbarAction` and `recordAction` refuse any payload (including `null`) with the same 422 they give `record_id`/`values`. `runAction` marshals `Data` once, rejects more than 256 KiB or an encode error with an opaque 500 plus `slog.Error("cabana: admin action data rejected", ...)`, and embeds the raw bytes so the envelope never double-encodes. Fill still passes `onlyFillScalars`.
- `cabana.AdminActionRequest.Payload` (`json:"payload,omitempty"`) and `cabana.AdminActionResult.Data` (`json:"data,omitempty"`) with swag doc comments; route descriptions updated.
- New root `.swaggo` with `replace json.RawMessage any`. Confirmed: without it swag emitted `cabana.AdminActionRequest` as a bare object; with it the regenerated `admin/src/api/schema.d.ts` has `payload?: unknown` next to `record_id?: number` and `values?:`, and `cabana.AdminActionResult` has `data?: unknown`, `fill`, `message`.
- Fixture `lookup` action (phase101_actions_test.go) decodes a payload and echoes it as `Data` next to a deliberately over-wide `Fill`; `"big"` and `"nan"` return rejected data.
- `TestWidgetPayloadAndData` (6 subtests): byte-exact pass-through and unfiltered data with filtered fill; every JSON value kind plus nil-when-absent; no `data` key when none returned (widget and toolbar); 65536-byte payload passes and 65537 is 422 with no action call; toolbar `{"payload":{}}`/`null`/`1` and record `activate` with `{"payload":1}` are 422 and the person stays inactive; `"big"`/`"nan"` are opaque 500s with no `xxxx` in the body.
- README/docs: cabana README bullet, route table and API rows; pact README rows; `docs/backend/partials-and-widgets.md` Go-contract paragraph; `docs/backend/admin-spa.md` mentions `.swaggo`.
**Task 2 (tdd, commit c6f68e4) - WidgetField.vue**
- `WIDGET_RESULT_EVENT = 'summer-result'` exported from formContext.ts.
- The listener reads `event.detail` and forwards the detail's own `payload` property (Object.hasOwn) or `undefined`; `onAction` spreads `payload` into the body only when defined, so payload-less bodies are byte-identical to before. On success, after the fill patch loop and before the toast, the component sets `data` to `JSON.stringify(answer.data)` when the response has an own `data` key (otherwise removes the attribute) and dispatches `summer-result` with `{data, fill, message}`, skipped when unmounted. Failure path unchanged.
- RED observed first: 7 new assertions failed on behaviour while all 22 existing tests passed; GREEN after the implementation: 89/89 across WidgetField, FormView and the extension smoke tests, `vue-tsc` clean, `modules/boardwalk/dist` rebuilt and matching `scripts/check-admin-dist.sh`.
**Task 3 (commit e723c39) - fixture widget and element-contract docs**
- `lookup.js` is now a reorder widget: button plus `<ol>`, in-memory items replaced from the observed `data` attribute and from a `summer-result` listener on itself, a click reverses the items and dispatches `summer-action` with `detail.payload.order`; textContent only, no HTTP, no innerHTML.
- `docs/backend/partials-and-widgets.md` gained a "Widget element contract" subsection (attributes, both events, the no-token/no-request rules, textContent guidance, pointer to the fixture). The cabana README bullet names both events.
## Verification
Commands run and outcomes (all at the final HEAD unless noted):
| Command | Result |
|---|---|
| `go vet ./...` | pass (after each task) |
| `go test ./modules/pact/...` | pass |
| `go test ./modules/cabana/ -run 'TestWidgetPayloadAndData\|TestPhase101Actions\|TestPhase10OpenAPIConformance\|TestRecordActionSmoke' -count=1` | pass (Task 1 gate) |
| `go test ./... -count=1` | pass, exit 0 (final gate, Docker-backed cabana tests included) |
| `scripts/check-admin-openapi.sh --check` | pass (after Task 1 and at HEAD) |
| `go test ./cmd/summer -run TestDocsTree` | pass (after Tasks 1 and 3) |
| `go run ./cmd/summer docs:build --check` | "no problems found" |
| `grep -c 'payload?: unknown' admin/src/api/schema.d.ts` | 1 |
| `grep -A12 '"cabana.AdminActionRequest": {' admin/src/api/schema.d.ts \| grep -c 'record_id?: number'` | 1 (see deviation note on `-A4`) |
| `npm --prefix admin test -- tests/form/WidgetField.test.ts tests/smoke/extension.smoke.test.ts tests/form/FormView.test.ts` | 89 passed |
| `npm --prefix admin test` (full suite) | 71 files, 1025 tests passed |
| `npm --prefix admin run typecheck` | pass |
| `scripts/check-admin-dist.sh` | matches a fresh build |
| `go test ./modules/boardwalk/...` | pass |
| `node --check modules/cabana/testdata/extension/assets/js/lookup.js` | pass |
| `! grep -q innerHTML modules/cabana/testdata/extension/assets/js/lookup.js` | pass |
| `test -z "$(git status --porcelain -- modules admin docs cmd internal scripts .swaggo)"` | clean |
## Deviations from Plan
**1. Verify command context width (Task 1).** The plan's literal check `grep -A4 '"cabana.AdminActionRequest": {' ... | grep -c 'record_id?: number'` returns 0 because openapi-typescript now emits the `Payload` doc comment (four lines) before `payload?: unknown`, pushing `record_id` past the 4-line window. The same check with `-A12` returns 1 and `values?:` is present too; the generated file was not hand-edited. The intent of the check (record_id and values survive the `.swaggo` override) is met.
**2. Task 2 TDD cycle committed as one commit.** RED (7 failing behaviour tests, 22 existing passing) was observed before implementing, then GREEN, but both landed in the single commit the plan prescribes ("one commit with formContext.ts, WidgetField.vue, WidgetField.test.ts and modules/boardwalk/dist"), keeping the project's green-at-every-commit rule. No separate `test(...)` commit exists.
**3. Fixture length.** `lookup.js` is about 60 lines, slightly over the plan's "roughly 50", to keep the observed-attribute parsing defensive (try/catch on JSON.parse, object filter on items).
None of these change behaviour or scope; the plan was otherwise executed as written.
## Threat model outcome
- T-Q261006-01 (payload DoS): mitigated, test "payload cap".
- T-Q261006-02 (data DoS): mitigated, test "data over 256 KiB or unencodable is an opaque 500".
- T-Q261006-03 (payload selecting the record): mitigated, record still loaded through `readScopedRecord`/FormExtendQuery; docs tell actions to re-scope ids in the payload; `TestPhase101Actions` out-of-scope 404s still pass.
- T-Q261006-04 (toolbar/record tampering): mitigated, test "toolbar and record routes refuse a payload".
- T-Q261006-05 (data bypasses fill): accepted and documented in README and docs.
- T-Q261006-06 (XSS via data): mitigated, SPA sets only an attribute string and dispatches an event; fixture and docs use textContent.
- T-Q261006-07, T-Q261006-SC: accepted; no new dependency, payload not logged.
No new threat surface beyond the plan's register.
## Known Stubs
None.
## Commits
| Hash | Subject |
|---|---|
| 6af88f9 | feat(cabana): widget action payload and data channel (quick-261006-eyj) |
| c6f68e4 | feat(admin): widget field payload and summer-result data channel (quick-261006-eyj) |
| e723c39 | docs(cabana): widget element contract and payload fixture (quick-261006-eyj) |
No `.planning` files and no co-author or "Generated with" trailers in any commit.
## Self-Check: PASSED
- `.swaggo`, `modules/cabana/widget_payload_test.go` exist; all listed modified files are in the three commits.
- Commits 6af88f9, c6f68e4, e723c39 are on `master` (`git rev-list --count 964145628..HEAD` = 3).