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 |
|
true |
|
|
|
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>
Facts established while planning (do not re-derive):
- swag v1.16.6 without
--parseDependencycannot resolvejson.RawMessage. Verified empirically: a struct with ajson.RawMessagefield is emitted as a bare{"type":"object"}with ALL of its properties dropped, sorecord_idandvalueswould vanish from the TypeScript type too. The fix is a type override file.swaggoat the repo root (swag's default--overridesFile, read relative to the cwd;scripts/check-admin-openapi.shdoescd "$ROOT"first) containingreplace json.RawMessage any. With it the field becomes an untyped{}schema,internal/tools/swagger2openapipasses it through, and openapi-typescript 7.13 renderspayload?: unknown. A Goanyfield (Data any) already renders as{}->data?: unknownwith no override. scripts/check-admin-openapi.shis howadmin/openapi/admin.jsonandadmin/src/api/schema.d.tsare 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 andadmin/node_modules(it runsnpm --prefix admin ciwhen missing); it does NOT need a running server.--checkdiffs against the committed files.npm --prefix admin run gen:apionly redoes the last step from the committed admin.json and is not sufficient here.- The embedded SPA build
modules/boardwalk/distis committed;admin/vite.config.tshasoutDir: '../modules/boardwalk/dist'. After any SPA change runnpm --prefix admin run build(vue-tsc then vite build) and commit the dist tree;scripts/check-admin-dist.shis the drift gate. - cabana database-backed tests use testcontainers PostgreSQL via
adminGorm(skips under-short).newActEnv(phase101_actions_test.go) boots theacme.demofixture with thelookupwidget action,recounttoolbar action and anactSpythat records everypact.AdminActionInput;newRosterEnv(phase121_fixture_test.go) bootsacme.rosterwhose people form declares record actions (activate,reinstate) reachable atrosterPeople + "/{id}/actions/{name}";rosterInsert,rosterLoad,actResult,actErrorCodeare reusable helpers. - Existing assertions that must keep passing untouched:
TestPhase101Actionstoolbar subtest comparescalls[0]to a zeropact.AdminActionInput{}(so Payload must be nil when absent); the "widget with an in-scope record" subtest posts without payload;admin/tests/form/WidgetField.test.tsandadmin/tests/smoke/extension.smoke.test.tsassert the element's attribute list at mount is exactlybusy-label, field-name, fill-values, label, locale, record-idand that a payload-less post body equals{ record_id, values }(nopayloadkey). TestPhase10OpenAPIConformancedecodes action responses intocabana.AdminActionResultwithDisallowUnknownFieldsand checks the$refnames inadmin/openapi/admin.json; addingdata,omitemptykeeps it green as long as admin.json is regenerated.- The docs identifier checker (
internal/docsite/check_identifiers.go, run bygo test ./cmd/summer -run TestDocsTree) verifies backticked spans shapedpkg.Identifier. Name types aspact.AdminActionInput/pact.AdminActionResult/cabana.AdminActionRequestand refer toPayloadandDataas 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).
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> |
<success_criteria>
- A widget element can send
detail.payloadonsummer-actionand 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
dataattribute and asummer-resultevent 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>