feat(cabana): widget action payload and data channel (quick-261006-eyj)

- pact.AdminActionInput.Payload (json.RawMessage) carries the widget's own
  JSON value untouched; pact.AdminActionResult.Data is passed through as data
- cabana decodes payload with a 64 KiB cap (422 on body), refuses it on the
  toolbar and record routes, and embeds Data once encoded with a 256 KiB cap
  (opaque 500 when larger or unencodable); fill stays filtered
- root .swaggo overrides json.RawMessage so swag keeps record_id and values;
  admin.json and schema.d.ts regenerated (payload?: unknown, data?: unknown)
- TestWidgetPayloadAndData covers pass-through, cap, refusal and data 500
- cabana and pact READMEs, partials-and-widgets and admin-spa docs updated
This commit is contained in:
Jakub Zych
2026-10-06 11:13:16 +02:00
parent 964145628a
commit 6af88f9df6
12 changed files with 257 additions and 32 deletions

7
.swaggo Normal file
View File

@@ -0,0 +1,7 @@
// Type overrides for swag (read by scripts/check-admin-openapi.sh, which runs
// swag v1.16.6 from the repository root). Without --parseDependency swag
// cannot resolve encoding/json.RawMessage and silently emits a struct that
// uses it as a bare object, dropping every one of its properties from
// admin/openapi/admin.json and the generated TypeScript types. Documenting
// json.RawMessage as an untyped value keeps cabana.AdminActionRequest whole.
replace json.RawMessage any

View File

@@ -3,6 +3,9 @@
"schemas": { "schemas": {
"cabana.AdminActionRequest": { "cabana.AdminActionRequest": {
"properties": { "properties": {
"payload": {
"description": "Payload is the widget's own JSON value (the summer-action event's\ndetail.payload): any JSON, at most 64 KiB, handed to the action as-is.\nToolbar and record routes refuse it."
},
"record_id": { "record_id": {
"type": "integer" "type": "integer"
}, },
@@ -15,6 +18,9 @@
}, },
"cabana.AdminActionResult": { "cabana.AdminActionResult": {
"properties": { "properties": {
"data": {
"description": "Data is the action's structured answer for the widget: any JSON value,\nnot filtered by fill, absent when the action returned none."
},
"fill": { "fill": {
"additionalProperties": {}, "additionalProperties": {},
"type": "object" "type": "object"
@@ -3614,7 +3620,7 @@
}, },
"/{vendor}/{plugin}/{controller}/toolbar/{action}": { "/{vendor}/{plugin}/{controller}/toolbar/{action}": {
"post": { "post": {
"description": "Runs a controller-registered action that the list's toolbar.buttons declares. The body must be {}: a toolbar action takes no record ids or values, and its fill is always empty.", "description": "Runs a controller-registered action that the list's toolbar.buttons declares. The body must be {}: record_id, values and payload are all refused, and the answer's fill is always empty.",
"parameters": [ "parameters": [
{ {
"description": "Vendor", "description": "Vendor",
@@ -3729,7 +3735,7 @@
}, },
"/{vendor}/{plugin}/{controller}/widgets/{field}": { "/{vendor}/{plugin}/{controller}/widgets/{field}": {
"post": { "post": {
"description": "Runs the controller action a `type: widget` field declares. The record is loaded through the controller's form scope (404 when out of scope); only the field's fill keys with scalar values reach the action and the response.", "description": "Runs the controller action a `type: widget` field declares. The record is loaded through the controller's form scope (404 when out of scope); only the field's fill keys with scalar values reach the action and the response. An optional payload (any JSON value, at most 64 KiB) is accepted and handed to the action as-is; the response may carry data, the action's own JSON answer, which is not subject to the fill filter.",
"parameters": [ "parameters": [
{ {
"description": "Vendor", "description": "Vendor",
@@ -3776,7 +3782,7 @@
} }
} }
}, },
"description": "Record id and fill snapshot", "description": "Record id, fill snapshot and optional payload",
"required": true "required": true
}, },
"responses": { "responses": {
@@ -4159,7 +4165,7 @@
}, },
"/{vendor}/{plugin}/{controller}/{id}/actions/{action}": { "/{vendor}/{plugin}/{controller}/{id}/actions/{action}": {
"post": { "post": {
"description": "Runs a record action the controller registers and the form's recordActions declares. The record is loaded and row-locked through the controller's form scope in one transaction (404 when missing or out of scope); an action that does not apply to the record's current state answers 409. The body must be {} and the answer's fill is always empty.", "description": "Runs a record action the controller registers and the form's recordActions declares. The record is loaded and row-locked through the controller's form scope in one transaction (404 when missing or out of scope); an action that does not apply to the record's current state answers 409. The body must be {}: record_id, values and payload are all refused, and the answer's fill is always empty.",
"parameters": [ "parameters": [
{ {
"description": "Vendor", "description": "Vendor",

View File

@@ -1433,7 +1433,7 @@ export interface paths {
put?: never; put?: never;
/** /**
* Run a toolbar action * Run a toolbar action
* @description Runs a controller-registered action that the list's toolbar.buttons declares. The body must be {}: a toolbar action takes no record ids or values, and its fill is always empty. * @description Runs a controller-registered action that the list's toolbar.buttons declares. The body must be {}: record_id, values and payload are all refused, and the answer's fill is always empty.
*/ */
post: { post: {
parameters: { parameters: {
@@ -1522,7 +1522,7 @@ export interface paths {
put?: never; put?: never;
/** /**
* Run a widget action * Run a widget action
* @description Runs the controller action a `type: widget` field declares. The record is loaded through the controller's form scope (404 when out of scope); only the field's fill keys with scalar values reach the action and the response. * @description Runs the controller action a `type: widget` field declares. The record is loaded through the controller's form scope (404 when out of scope); only the field's fill keys with scalar values reach the action and the response. An optional payload (any JSON value, at most 64 KiB) is accepted and handed to the action as-is; the response may carry data, the action's own JSON answer, which is not subject to the fill filter.
*/ */
post: { post: {
parameters: { parameters: {
@@ -1540,7 +1540,7 @@ export interface paths {
}; };
cookie?: never; cookie?: never;
}; };
/** @description Record id and fill snapshot */ /** @description Record id, fill snapshot and optional payload */
requestBody: { requestBody: {
content: { content: {
"application/json": components["schemas"]["cabana.AdminActionRequest"]; "application/json": components["schemas"]["cabana.AdminActionRequest"];
@@ -1824,7 +1824,7 @@ export interface paths {
put?: never; put?: never;
/** /**
* Run a declared record action * Run a declared record action
* @description Runs a record action the controller registers and the form's recordActions declares. The record is loaded and row-locked through the controller's form scope in one transaction (404 when missing or out of scope); an action that does not apply to the record's current state answers 409. The body must be {} and the answer's fill is always empty. * @description Runs a record action the controller registers and the form's recordActions declares. The record is loaded and row-locked through the controller's form scope in one transaction (404 when missing or out of scope); an action that does not apply to the record's current state answers 409. The body must be {}: record_id, values and payload are all refused, and the answer's fill is always empty.
*/ */
post: { post: {
parameters: { parameters: {
@@ -4198,12 +4198,23 @@ export type webhooks = Record<string, never>;
export interface components { export interface components {
schemas: { schemas: {
"cabana.AdminActionRequest": { "cabana.AdminActionRequest": {
/**
* @description Payload is the widget's own JSON value (the summer-action event's
* detail.payload): any JSON, at most 64 KiB, handed to the action as-is.
* Toolbar and record routes refuse it.
*/
payload?: unknown;
record_id?: number; record_id?: number;
values?: { values?: {
[key: string]: unknown; [key: string]: unknown;
}; };
}; };
"cabana.AdminActionResult": { "cabana.AdminActionResult": {
/**
* @description Data is the action's structured answer for the widget: any JSON value,
* not filtered by fill, absent when the action returned none.
*/
data?: unknown;
fill: { fill: {
[key: string]: unknown; [key: string]: unknown;
}; };

View File

@@ -35,7 +35,7 @@ A form whose schema carries `preview` has a read-only record screen at `<control
## Types from OpenAPI ## Types from OpenAPI
The admin API is described by swag annotations in cabana. `scripts/check-admin-openapi.sh` generates the OpenAPI document (`admin/openapi/admin.json`) from them and the SPA's TypeScript types (`admin/src/api/schema.d.ts`) from the document, so the SPA's API client is checked against the server's shapes at compile time. `--check` fails when either committed file is out of date: The admin API is described by swag annotations in cabana. `scripts/check-admin-openapi.sh` generates the OpenAPI document (`admin/openapi/admin.json`) from them and the SPA's TypeScript types (`admin/src/api/schema.d.ts`) from the document, so the SPA's API client is checked against the server's shapes at compile time. Type overrides for swag live in the root `.swaggo` (it documents `json.RawMessage` as an untyped value, which swag cannot resolve on its own). `--check` fails when either committed file is out of date:
```sh ```sh
scripts/check-admin-openapi.sh --check scripts/check-admin-openapi.sh --check

View File

@@ -70,7 +70,7 @@ lookup:
- `action` names an action the controller registers through `pact.HasAdminActions`. - `action` names an action the controller registers through `pact.HasAdminActions`.
- `fill` lists the writable scalar fields of the same form the action may write back. - `fill` lists the writable scalar fields of the same form the action may write back.
The SPA posts the widget's values to `.../widgets/{field}`. cabana checks the CSRF header, the controller's and the action's permissions and the record scope, then calls the action's `Run` with a `pact.AdminActionInput`. The answer's `pact.AdminActionResult` carries a message and the fill values; keys outside `fill` and values that are not scalars are dropped before the response is written. An action may return a `cabana.ValidationError` to answer 422 on a field. The SPA posts `record_id`, the fill snapshot `values` and, when the element supplied one, `payload` to `.../widgets/{field}`. cabana checks the CSRF header, the controller's and the action's permissions and the record scope, then calls the action's `Run` with a `pact.AdminActionInput`. The action reads the payload from the `Payload` field as raw JSON (any value up to 64 KiB, exactly the bytes the client sent, nil when absent) and decodes it itself; it must treat any ids inside the payload as untrusted input and re-check them against its own scope, because the framework never uses the payload to select the record. The answer's `pact.AdminActionResult` carries a message and the fill values; keys outside `fill` and values that are not scalars are dropped before the response is written. It may also carry `Data`, any JSON value up to 256 KiB encoded, which is passed through as `data` and is not filtered like `fill` (a larger or unencodable value is a 500). Toolbar and record actions refuse a payload. An action may return a `cabana.ValidationError` to answer 422 on a field.
## Toolbar actions ## Toolbar actions

View File

@@ -14,7 +14,7 @@ Schema-driven admin backend that compiles WinterCMS-style YAML list, form, filte
- Generic CRUD with `cabana.CRUDService`: list, show, create, update, delete and bulk delete. Writes run in transactions, and reads and writes are scoped by the controller's `pact.ListExtendQuery` and `pact.FormExtendQuery` hooks. `cabana.ExecuteList` applies search, sort, filters and pagination only on columns declared in the schema, so request parameters never reach SQL directly. - Generic CRUD with `cabana.CRUDService`: list, show, create, update, delete and bulk delete. Writes run in transactions, and reads and writes are scoped by the controller's `pact.ListExtendQuery` and `pact.FormExtendQuery` hooks. `cabana.ExecuteList` applies search, sort, filters and pagination only on columns declared in the schema, so request parameters never reach SQL directly.
- Mass-assignment protection: writable form fields are bound to model columns at activation (`cabana.BindWritableFields`), and `cabana.ProjectWritableFields` drops unknown keys, case variants, nested objects and protected columns from request bodies. Values are filled and validated through [lagoon](../lagoon/README.md); a value that does not fit its column (a `lagoon.FillTypeError`, such as a fraction for an integer field) is a 422 `validation_failed` on that field, and the form lifecycle hooks declared in `pact` (before and after create, update and delete) run around each write. - Mass-assignment protection: writable form fields are bound to model columns at activation (`cabana.BindWritableFields`), and `cabana.ProjectWritableFields` drops unknown keys, case variants, nested objects and protected columns from request bodies. Values are filled and validated through [lagoon](../lagoon/README.md); a value that does not fit its column (a `lagoon.FillTypeError`, such as a fraction for an integer field) is a 422 `validation_failed` on that field, and the form lifecycle hooks declared in `pact` (before and after create, update and delete) run around each write.
- Relations: `type: relation` form fields for belongsTo and belongsToMany (`cabana.FieldRelationProvider`, `cabana.FieldRelationContract`) with a paginated options endpoint and display labels in every record response; relation managers (`cabana.AdminRelationContractProvider`, `cabana.RelationContract`) served by `cabana.RelationService` for listing linked records and candidates, linking and unlinking, and creating related records. A contract is a belongsToMany (`cabana.RelationBelongsToMany`, the kind of a contract that leaves `Kind` empty) with a pivot model, or a hasMany (`cabana.RelationHasMany`) whose `ForeignKey` column on the related model points at the parent. A relation's child form comes from `manage.form` in `config_relation.yaml` (or a top-level `form`, WinterCMS's fallback), its read-only preview from `view.form` (same fallback), and a belongsToMany's pivot columns are edited through `pivot.form` (WinterCMS `pivot[x]` field names compile to `x`; pivot keys, timestamps and hook columns are refused); WinterCMS `$/<vendor>/<plugin>/...` paths resolve inside the same plugin only. A relation form accepts the scalar field types plus `datepicker` and `fileupload`; `relation`, `relation-manager`, `widget` and `partial` fail boot. The view panel's `toolbarButtons` (`create`, `update`, `delete`, `link`, `unlink`) are the capability of their routes. A relation's `messages` block takes the link keys (`link`, `linkHint`, `candidateSearch`, `linked`, `unlinkSelected`, `unlinkConfirm`, `unlinked`, `empty`) and the child and pivot modal keys (`create`, `createTitle`, `updateTitle`, `previewTitle`, `created`, `updated`, `deleteSelected`, `deleteConfirm`, `deleteOneConfirm`, `deleted`, `pivotTitle`, `pivotSaved`, `editPivot`, `createSubmit`, `updateSubmit`, `pivotSubmit`, `linkSubmit`), each defaulting to `backend::lang.messages.relation.*`. Framework code never guesses table, pivot or foreign-key names: the controller supplies them. A belongsTo field over a protected foreign key is read-only unless its contract sets `WritableForeignKey`; the protected key list itself does not change, and the submitted id is still rechecked through the scoped options query. A controller implementing `cabana.RelationLockProvider` returns a `cabana.RelationLock` per field and request: options and labels carry `locked: true` for those ids, and a create or update that adds or removes a locked id is answered 403 `forbidden` before any row is written. - Relations: `type: relation` form fields for belongsTo and belongsToMany (`cabana.FieldRelationProvider`, `cabana.FieldRelationContract`) with a paginated options endpoint and display labels in every record response; relation managers (`cabana.AdminRelationContractProvider`, `cabana.RelationContract`) served by `cabana.RelationService` for listing linked records and candidates, linking and unlinking, and creating related records. A contract is a belongsToMany (`cabana.RelationBelongsToMany`, the kind of a contract that leaves `Kind` empty) with a pivot model, or a hasMany (`cabana.RelationHasMany`) whose `ForeignKey` column on the related model points at the parent. A relation's child form comes from `manage.form` in `config_relation.yaml` (or a top-level `form`, WinterCMS's fallback), its read-only preview from `view.form` (same fallback), and a belongsToMany's pivot columns are edited through `pivot.form` (WinterCMS `pivot[x]` field names compile to `x`; pivot keys, timestamps and hook columns are refused); WinterCMS `$/<vendor>/<plugin>/...` paths resolve inside the same plugin only. A relation form accepts the scalar field types plus `datepicker` and `fileupload`; `relation`, `relation-manager`, `widget` and `partial` fail boot. The view panel's `toolbarButtons` (`create`, `update`, `delete`, `link`, `unlink`) are the capability of their routes. A relation's `messages` block takes the link keys (`link`, `linkHint`, `candidateSearch`, `linked`, `unlinkSelected`, `unlinkConfirm`, `unlinked`, `empty`) and the child and pivot modal keys (`create`, `createTitle`, `updateTitle`, `previewTitle`, `created`, `updated`, `deleteSelected`, `deleteConfirm`, `deleteOneConfirm`, `deleted`, `pivotTitle`, `pivotSaved`, `editPivot`, `createSubmit`, `updateSubmit`, `pivotSubmit`, `linkSubmit`), each defaulting to `backend::lang.messages.relation.*`. Framework code never guesses table, pivot or foreign-key names: the controller supplies them. A belongsTo field over a protected foreign key is read-only unless its contract sets `WritableForeignKey`; the protected key list itself does not change, and the submitted id is still rechecked through the scoped options query. A controller implementing `cabana.RelationLockProvider` returns a `cabana.RelationLock` per field and request: options and labels carry `locked: true` for those ids, and a create or update that adds or removes a locked id is answered 403 `forbidden` before any row is written.
- Form widgets and controller actions: a `type: widget` field in `fields.yaml` names a plugin custom element (`widget:`, which must start with the owning plugin's `{vendor}-{plugin}-` prefix), the controller action it runs (`action:`, registered through `pact.HasAdminActions`) and the writable scalar fields of the same form the action may write back (`fill:`). The admin SPA posts the action to a cabana-owned route, so the CSRF check, permissions (the controller's plus the action's own) and record scoping (`pact.FormExtendQuery`) never depend on plugin code; the response carries only the declared fill keys whose values encode as JSON scalars (a value whose `MarshalJSON` writes an array or object, NaN or an infinity is dropped). Like the list schema's `toolbarActions`, the form schema carries a widget field only when the requesting administrator may run its action. The field's `context` applies to the action route as it does on save: a request without `record_id` is the create form's and one with it the update form's, and a widget its context hides on that form answers 404. Unknown keys, a foreign or invalid tag, an unregistered action or a fill key that is not a writable scalar field fail boot. - Form widgets and controller actions: a `type: widget` field in `fields.yaml` names a plugin custom element (`widget:`, which must start with the owning plugin's `{vendor}-{plugin}-` prefix), the controller action it runs (`action:`, registered through `pact.HasAdminActions`) and the writable scalar fields of the same form the action may write back (`fill:`). The admin SPA posts the action to a cabana-owned route, so the CSRF check, permissions (the controller's plus the action's own) and record scoping (`pact.FormExtendQuery`) never depend on plugin code; the response carries only the declared fill keys whose values encode as JSON scalars (a value whose `MarshalJSON` writes an array or object, NaN or an infinity is dropped). Like the list schema's `toolbarActions`, the form schema carries a widget field only when the requesting administrator may run its action. The field's `context` applies to the action route as it does on save: a request without `record_id` is the create form's and one with it the update form's, and a widget its context hides on that form answers 404. Besides `record_id` and `values`, the request may carry `payload`, the widget's own JSON value (at most 64 KiB, a 422 above that; refused on toolbar and record routes), which reaches the action unfiltered as the `Payload` field of `pact.AdminActionInput`; the action may answer with `Data` on `pact.AdminActionResult`, written to the response as `data` exactly as it encodes (any JSON up to 256 KiB encoded, a 500 above that or when it cannot be encoded), not subject to the fill allowlist and omitted when nil. Unknown keys, a foreign or invalid tag, an unregistered action or a fill key that is not a writable scalar field fail boot.
- Controller assets: a controller implementing `pact.AdminClientAssets` names JS (`.js`, `.mjs`) and CSS files under its plugin's `assets/` directory, Winter's `addJs`/`addCss`. They are read from the plugin's embedded tree at boot (a missing file fails boot; there is no disk override) and listed in the list and form schemas under `assets` as same-origin URLs with a `?v=` content hash. A form with a widget needs at least one JS file. - Controller assets: a controller implementing `pact.AdminClientAssets` names JS (`.js`, `.mjs`) and CSS files under its plugin's `assets/` directory, Winter's `addJs`/`addCss`. They are read from the plugin's embedded tree at boot (a missing file fails boot; there is no disk override) and listed in the list and form schemas under `assets` as same-origin URLs with a `?v=` content hash. A form with a widget needs at least one JS file.
- Toolbar actions: `toolbar.buttons` in `config_list.yaml` lists the built-in `create` and `delete` next to names the controller registers through `pact.HasAdminActions`. Registered actions share one namespace with widget actions, `create` and `delete` are reserved, and each toolbar action needs a label. The list schema's `toolbarActions` carries only the actions the requesting administrator may run, with localized labels; an unknown name fails boot. - Toolbar actions: `toolbar.buttons` in `config_list.yaml` lists the built-in `create` and `delete` next to names the controller registers through `pact.HasAdminActions`. Registered actions share one namespace with widget actions, `create` and `delete` are reserved, and each toolbar action needs a label. The list schema's `toolbarActions` carries only the actions the requesting administrator may run, with localized labels; an unknown name fails boot.
- Bulk actions: `bulkActions` in `config_list.yaml` lists names the controller registers through `pact.HasAdminBulkActions`; it needs `showCheckboxes: true`. Bulk actions have their own namespace (`create` and `delete` are reserved there too), and each needs a label. The posted ids are resolved and row-locked through `pact.ListExtendQuery` in one transaction and the action receives the loaded records, never ids: a selection that matches nothing answers `affected: 0` without running the action, and a partial match answers 409 and rolls back. The list schema's `bulkActions` carries the built-in `delete` and only the declared actions the requesting administrator may run, with localized `label` and `confirm`; an unknown or duplicate name fails boot. Each run is logged with the controller, action, administrator and affected count. - Bulk actions: `bulkActions` in `config_list.yaml` lists names the controller registers through `pact.HasAdminBulkActions`; it needs `showCheckboxes: true`. Bulk actions have their own namespace (`create` and `delete` are reserved there too), and each needs a label. The posted ids are resolved and row-locked through `pact.ListExtendQuery` in one transaction and the action receives the loaded records, never ids: a selection that matches nothing answers `affected: 0` without running the action, and a partial match answers 409 and rolls back. The list schema's `bulkActions` carries the built-in `delete` and only the declared actions the requesting administrator may run, with localized `label` and `confirm`; an unknown or duplicate name fails boot. Each run is logged with the controller, action, administrator and affected count.
@@ -53,7 +53,7 @@ All paths are relative to `<prefix>/api/v1`. A controller ID `vendor.plugin.cont
| POST `/{vendor}/{plugin}/{controller}/bulk-delete` | Delete a set of records in one transaction; needs `delete` in `toolbar.buttons`. | | POST `/{vendor}/{plugin}/{controller}/bulk-delete` | Delete a set of records in one transaction; needs `delete` in `toolbar.buttons`. |
| POST `/{vendor}/{plugin}/{controller}/bulk/{action}` | Run a declared bulk action on `{ids}` in one transaction; answers `{message, affected}`, 409 for a partial selection. | | POST `/{vendor}/{plugin}/{controller}/bulk/{action}` | Run a declared bulk action on `{ids}` in one transaction; answers `{message, affected}`, 409 for a partial selection. |
| POST `/{vendor}/{plugin}/{controller}/{id}/actions/{action}` | Run a declared record action with an empty `{}` body in one transaction; answers `{message, fill: {}}`, 404 outside the form scope, 409 when the action does not apply. | | POST `/{vendor}/{plugin}/{controller}/{id}/actions/{action}` | Run a declared record action with an empty `{}` body in one transaction; answers `{message, fill: {}}`, 404 outside the form scope, 409 when the action does not apply. |
| POST `/{vendor}/{plugin}/{controller}/widgets/{field}` | Run the action of a `type: widget` field with an optional `record_id` and the fill snapshot; answers `{message, fill}`. | | POST `/{vendor}/{plugin}/{controller}/widgets/{field}` | Run the action of a `type: widget` field with an optional `record_id`, the fill snapshot and an optional `payload` (any JSON, 64 KiB); answers `{message, fill, data?}`. |
| POST `/{vendor}/{plugin}/{controller}/toolbar/{action}` | Run a registered toolbar action with an empty `{}` body; answers `{message, fill: {}}`. | | POST `/{vendor}/{plugin}/{controller}/toolbar/{action}` | Run a registered toolbar action with an empty `{}` body; answers `{message, fill: {}}`. |
| GET `/{vendor}/{plugin}/{controller}/partials/{name}` | Render a declared header or form partial as a node tree; `?id=` (form partials only) passes the scoped record to the view model. | | GET `/{vendor}/{plugin}/{controller}/partials/{name}` | Render a declared header or form partial as a node tree; `?id=` (form partials only) passes the scoped record to the view model. |
| GET `.../fields/{field}/options`, GET `.../filters/{scope}/options` | Choices for a relation field and for a model-backed list filter. | | GET `.../fields/{field}/options`, GET `.../filters/{scope}/options` | Choices for a relation field and for a model-backed list filter. |
@@ -219,8 +219,8 @@ func (p *Plugin) AdminFS() fs.FS { return adminFS }
| `cabana.WriteData` / `cabana.WriteError` / `cabana.WriteErrorDetails` | Write the admin success and error envelopes. | | `cabana.WriteData` / `cabana.WriteError` / `cabana.WriteErrorDetails` | Write the admin success and error envelopes. |
| `cabana.ValidationError` / `cabana.ListValidationError` | Field-level `validation_failed` errors. A `pact.AdminAction` may return a `cabana.ValidationError` to answer 422. | | `cabana.ValidationError` / `cabana.ListValidationError` | Field-level `validation_failed` errors. A `pact.AdminAction` may return a `cabana.ValidationError` to answer 422. |
| `cabana.ForbiddenError` | A write controller code refuses: a hook or an action returns it and the route answers 403 `forbidden` with its localized `Message` and `Details`; the transaction is rolled back. | | `cabana.ForbiddenError` | A write controller code refuses: a hook or an action returns it and the route answers 403 `forbidden` with its localized `Message` and `Details`; the transaction is rolled back. |
| `cabana.AdminActionRequest` | Body of an action route: optional `record_id` and the widget's `values`. Unknown keys are refused. | | `cabana.AdminActionRequest` | Body of an action route: optional `record_id`, the widget's `values` and an optional `payload` (any JSON value, at most 64 KiB, widget route only). Unknown keys are refused. |
| `cabana.AdminActionResult` | Answer of an action route: the localized `message` and the filtered `fill` object. | | `cabana.AdminActionResult` | Answer of an action route: the localized `message`, the filtered `fill` object and, when the action returned one, `data` exactly as returned. |
| `cabana.ControllerAssets` | The `assets` object of list and form schemas: `scripts` and `styles` URL lists, always arrays. | | `cabana.ControllerAssets` | The `assets` object of list and form schemas: `scripts` and `styles` URL lists, always arrays. |
| `cabana.ToolbarAction` | One registered toolbar button in a list schema's `toolbarActions`: action name and localized label. | | `cabana.ToolbarAction` | One registered toolbar button in a list schema's `toolbarActions`: action name and localized label. |
| `cabana.SessionKeyHeader` | `X-Session-Key`, the header that carries the form session key of deferred file and relation work. | | `cabana.SessionKeyHeader` | `X-Session-Key`, the header that carries the form session key of deferred file and relation work. |

View File

@@ -15,11 +15,21 @@ import (
"gorm.io/gorm/clause" "gorm.io/gorm/clause"
) )
// maxActionPayloadBytes caps the widget's own payload (the body key payload):
// a larger value is a 422 on body and the action never runs.
const maxActionPayloadBytes = 64 << 10
// maxActionDataBytes caps an action's encoded Data: a larger or unencodable
// value is logged and answered with the opaque 500 body.
const maxActionDataBytes = 256 << 10
// widgetAction serves POST .../{controller}/widgets/{field} (D-05, D-07): the // widgetAction serves POST .../{controller}/widgets/{field} (D-05, D-07): the
// SPA posts on behalf of a `type: widget` field, cabana checks the controller // SPA posts on behalf of a `type: widget` field, cabana checks the controller
// and action permissions, loads record_id through the controller's form scope // and action permissions, loads record_id through the controller's form scope
// and runs the registered action. Only the field's declared fill keys with // and runs the registered action. Only the field's declared fill keys with
// scalar values reach the action and the response. // scalar values reach the action and the response; the optional payload
// passes through to the action exactly as sent, never inspected and never
// used to select the record.
func (s *service) widgetAction(w http.ResponseWriter, r *http.Request) { func (s *service) widgetAction(w http.ResponseWriter, r *http.Request) {
s.protect(w, r, func(cc *CompiledController) { s.protect(w, r, func(cc *CompiledController) {
field, ok := widgetField(cc, r.PathValue("field")) field, ok := widgetField(cc, r.PathValue("field"))
@@ -52,6 +62,7 @@ func (s *service) widgetAction(w http.ResponseWriter, r *http.Request) {
return return
} }
input := pact.AdminActionInput{Field: field.Name, Values: onlyFillScalars(field.Fill, in.Values)} input := pact.AdminActionInput{Field: field.Name, Values: onlyFillScalars(field.Fill, in.Values)}
input.Payload = in.Payload
if in.RecordID != nil { if in.RecordID != nil {
db, err := s.db() db, err := s.db()
if err != nil { if err != nil {
@@ -90,8 +101,8 @@ func (s *service) toolbarAction(w http.ResponseWriter, r *http.Request) {
writeCRUDError(w, err) writeCRUDError(w, err)
return return
} }
if in.RecordID != nil || in.Values != nil { if in.RecordID != nil || in.Values != nil || len(in.Payload) > 0 {
writeCRUDError(w, &ValidationError{Details: map[string]any{"body": []string{"A toolbar action takes no record_id or values."}}}) writeCRUDError(w, &ValidationError{Details: map[string]any{"body": []string{"A toolbar action takes no record_id, values or payload."}}})
return return
} }
s.runAction(w, r, cc, action, pact.AdminActionInput{}, nil) s.runAction(w, r, cc, action, pact.AdminActionInput{}, nil)
@@ -197,8 +208,8 @@ func (s *service) recordAction(w http.ResponseWriter, r *http.Request) {
writeCRUDError(w, err) writeCRUDError(w, err)
return return
} }
if in.RecordID != nil || in.Values != nil { if in.RecordID != nil || in.Values != nil || len(in.Payload) > 0 {
writeCRUDError(w, &ValidationError{Details: map[string]any{"body": []string{"A record action takes no record_id or values."}}}) writeCRUDError(w, &ValidationError{Details: map[string]any{"body": []string{"A record action takes no record_id, values or payload."}}})
return return
} }
svc, err := s.crud() svc, err := s.crud()
@@ -256,7 +267,10 @@ func (s *service) allowAction(w http.ResponseWriter, r *http.Request, permission
// runAction calls the plugin's Run and writes the D-10 envelope. A // runAction calls the plugin's Run and writes the D-10 envelope. A
// *ValidationError is a 422 and a *ForbiddenError a 403; any other error is // *ValidationError is a 422 and a *ForbiddenError a 403; any other error is
// logged and answered with the generic 500 body, never the error text. // logged and answered with the generic 500 body, never the error text. The
// result's Fill passes the fill filter; its Data bypasses that filter but not
// the size cap: it is encoded once and embedded as-is, and a value above
// maxActionDataBytes or one that cannot be encoded is an opaque 500.
func (s *service) runAction(w http.ResponseWriter, r *http.Request, cc *CompiledController, action pact.AdminAction, input pact.AdminActionInput, fill []string) { func (s *service) runAction(w http.ResponseWriter, r *http.Request, cc *CompiledController, action pact.AdminAction, input pact.AdminActionInput, fill []string) {
tr := s.translator() tr := s.translator()
ctx := towel.WithLocale(r.Context(), schemaLocale(r.Context(), tr)) ctx := towel.WithLocale(r.Context(), schemaLocale(r.Context(), tr))
@@ -277,10 +291,20 @@ func (s *service) runAction(w http.ResponseWriter, r *http.Request, cc *Compiled
WriteError(w, http.StatusInternalServerError, "error", msgServerError) WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return return
} }
WriteData(w, http.StatusOK, AdminActionResult{ out := AdminActionResult{
Message: translateKey(ctx, tr, result.Message), Message: translateKey(ctx, tr, result.Message),
Fill: onlyFillScalars(fill, result.Fill), Fill: onlyFillScalars(fill, result.Fill),
}, nil) }
if result.Data != nil {
raw, err := json.Marshal(result.Data)
if err != nil || len(raw) > maxActionDataBytes {
slog.Error("cabana: admin action data rejected", "controller", controllerID(cc), "action", action.Name, "field", input.Field, "bytes", len(raw), "error", err)
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
out.Data = json.RawMessage(raw)
}
WriteData(w, http.StatusOK, out, nil)
} }
// widgetField returns the form's `type: widget` field with the given name. // widgetField returns the form's `type: widget` field with the given name.
@@ -296,8 +320,10 @@ func widgetField(cc *CompiledController, name string) (FormField, bool) {
return FormField{}, false return FormField{}, false
} }
// decodeActionRequest decodes the strict {record_id, values} body: unknown // decodeActionRequest decodes the strict {record_id, values, payload} body:
// keys, a malformed body or trailing tokens are a validation failure (422). // unknown keys, a malformed body or trailing tokens are a validation failure
// (422), and so is a payload above maxActionPayloadBytes. The payload bytes
// are kept exactly as sent.
func decodeActionRequest(r *http.Request) (AdminActionRequest, error) { func decodeActionRequest(r *http.Request) (AdminActionRequest, error) {
invalid := &ValidationError{Details: map[string]any{"body": []string{"The request body is invalid."}}} invalid := &ValidationError{Details: map[string]any{"body": []string{"The request body is invalid."}}}
dec := json.NewDecoder(r.Body) dec := json.NewDecoder(r.Body)
@@ -311,6 +337,9 @@ func decodeActionRequest(r *http.Request) (AdminActionRequest, error) {
if err := dec.Decode(&trailing); err != io.EOF { if err := dec.Decode(&trailing); err != io.EOF {
return AdminActionRequest{}, invalid return AdminActionRequest{}, invalid
} }
if len(in.Payload) > maxActionPayloadBytes {
return AdminActionRequest{}, &ValidationError{Details: map[string]any{"body": []string{"The payload may not exceed 64 KiB."}}}
}
return in, nil return in, nil
} }

View File

@@ -15,6 +15,8 @@ package cabana
// http.go is the runtime route table, and TestPhase09PermissionMatrix plus // http.go is the runtime route table, and TestPhase09PermissionMatrix plus
// TestPhase09ContractInventory fail if the two lists diverge. // TestPhase09ContractInventory fail if the two lists diverge.
import "encoding/json"
// ErrorBody is one D-10 error object. // ErrorBody is one D-10 error object.
type ErrorBody struct { type ErrorBody struct {
Code string `json:"code"` Code string `json:"code"`
@@ -423,7 +425,7 @@ func AdminBulkAction() {}
// AdminRecordAction documents the declared record action route. // AdminRecordAction documents the declared record action route.
// //
// @Summary Run a declared record action // @Summary Run a declared record action
// @Description Runs a record action the controller registers and the form's recordActions declares. The record is loaded and row-locked through the controller's form scope in one transaction (404 when missing or out of scope); an action that does not apply to the record's current state answers 409. The body must be {} and the answer's fill is always empty. // @Description Runs a record action the controller registers and the form's recordActions declares. The record is loaded and row-locked through the controller's form scope in one transaction (404 when missing or out of scope); an action that does not apply to the record's current state answers 409. The body must be {}: record_id, values and payload are all refused, and the answer's fill is always empty.
// @Tags admin // @Tags admin
// @Accept json // @Accept json
// @Produce json // @Produce json
@@ -446,10 +448,14 @@ func AdminRecordAction() {}
// AdminActionRequest is the body of a widget or toolbar action. record_id is // AdminActionRequest is the body of a widget or toolbar action. record_id is
// the record a widget on the update form belongs to (absent on create and // the record a widget on the update form belongs to (absent on create and
// always absent for a toolbar action); values is the widget's snapshot of its // always absent for a toolbar action); values is the widget's snapshot of its
// fill fields. // fill fields; payload is the widget's own JSON value.
type AdminActionRequest struct { type AdminActionRequest struct {
RecordID *uint64 `json:"record_id,omitempty"` RecordID *uint64 `json:"record_id,omitempty"`
Values map[string]any `json:"values,omitempty"` Values map[string]any `json:"values,omitempty"`
// Payload is the widget's own JSON value (the summer-action event's
// detail.payload): any JSON, at most 64 KiB, handed to the action as-is.
// Toolbar and record routes refuse it.
Payload json.RawMessage `json:"payload,omitempty"`
} }
// AdminActionResult is an action's answer: a localized message for the toast // AdminActionResult is an action's answer: a localized message for the toast
@@ -458,12 +464,15 @@ type AdminActionRequest struct {
type AdminActionResult struct { type AdminActionResult struct {
Message string `json:"message"` Message string `json:"message"`
Fill map[string]any `json:"fill"` Fill map[string]any `json:"fill"`
// Data is the action's structured answer for the widget: any JSON value,
// not filtered by fill, absent when the action returned none.
Data any `json:"data,omitempty"`
} }
// AdminWidgetAction documents the widget action route. // AdminWidgetAction documents the widget action route.
// //
// @Summary Run a widget action // @Summary Run a widget action
// @Description Runs the controller action a `type: widget` field declares. The record is loaded through the controller's form scope (404 when out of scope); only the field's fill keys with scalar values reach the action and the response. // @Description Runs the controller action a `type: widget` field declares. The record is loaded through the controller's form scope (404 when out of scope); only the field's fill keys with scalar values reach the action and the response. An optional payload (any JSON value, at most 64 KiB) is accepted and handed to the action as-is; the response may carry data, the action's own JSON answer, which is not subject to the fill filter.
// @Tags admin // @Tags admin
// @Accept json // @Accept json
// @Produce json // @Produce json
@@ -472,7 +481,7 @@ type AdminActionResult struct {
// @Param plugin path string true "Plugin" // @Param plugin path string true "Plugin"
// @Param controller path string true "Controller" // @Param controller path string true "Controller"
// @Param field path string true "Widget field name" // @Param field path string true "Widget field name"
// @Param body body AdminActionRequest true "Record id and fill snapshot" // @Param body body AdminActionRequest true "Record id, fill snapshot and optional payload"
// @Success 200 {object} Envelope[AdminActionResult] // @Success 200 {object} Envelope[AdminActionResult]
// @Failure 401 {object} ErrorEnvelope // @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope // @Failure 403 {object} ErrorEnvelope
@@ -484,7 +493,7 @@ func AdminWidgetAction() {}
// AdminToolbarAction documents the toolbar action route. // AdminToolbarAction documents the toolbar action route.
// //
// @Summary Run a toolbar action // @Summary Run a toolbar action
// @Description Runs a controller-registered action that the list's toolbar.buttons declares. The body must be {}: a toolbar action takes no record ids or values, and its fill is always empty. // @Description Runs a controller-registered action that the list's toolbar.buttons declares. The body must be {}: record_id, values and payload are all refused, and the answer's fill is always empty.
// @Tags admin // @Tags admin
// @Accept json // @Accept json
// @Produce json // @Produce json

View File

@@ -135,6 +135,29 @@ func (c actController) AdminActions() []pact.AdminAction {
Name: "lookup", Label: "acme.demo::lang.gadgets.lookup", Permissions: []string{"acme.demo.run"}, Name: "lookup", Label: "acme.demo::lang.gadgets.lookup", Permissions: []string{"acme.demo.run"},
Run: func(_ context.Context, in pact.AdminActionInput) (pact.AdminActionResult, error) { Run: func(_ context.Context, in pact.AdminActionInput) (pact.AdminActionResult, error) {
c.spy.record(in) c.spy.record(in)
if len(in.Payload) > 0 {
// The payload round trip: echo the decoded payload as data
// next to a fill that must still be filtered; "big" and
// "nan" answer data the framework must reject.
var payload any
if err := json.Unmarshal(in.Payload, &payload); err != nil {
return pact.AdminActionResult{}, err
}
switch payload {
case "big":
return pact.AdminActionResult{Data: strings.Repeat("x", 256<<10+1)}, nil
case "nan":
return pact.AdminActionResult{Data: math.NaN()}, nil
}
return pact.AdminActionResult{
Message: "acme.demo::lang.gadgets.looked_up",
Fill: map[string]any{"name": "reordered", "tenant": "other", "active": []string{"a"}},
Data: map[string]any{
"echo": payload,
"items": []map[string]any{{"id": 1, "title": "a"}},
},
}, nil
}
switch in.Values["name"] { switch in.Values["name"] {
case "invalid": case "invalid":
return pact.AdminActionResult{}, &cabana.ValidationError{Details: map[string]any{"name": []string{"Name is taken."}}} return pact.AdminActionResult{}, &cabana.ValidationError{Details: map[string]any{"name": []string{"Name is taken."}}}

View File

@@ -0,0 +1,126 @@
package cabana_test
import (
"encoding/json"
"fmt"
"net/http"
"reflect"
"strings"
"testing"
)
// TestWidgetPayloadAndData drives the widget payload and data channel through
// the assembled router on PostgreSQL (quick-261006-eyj; T-Q261006-01, -02,
// -04): the payload reaches the action byte for byte, data comes back
// unfiltered while fill is still filtered, the 64 KiB payload cap, the
// toolbar and record refusal, and the 256 KiB or unencodable data as an
// opaque 500.
func TestWidgetPayloadAndData(t *testing.T) {
env, gdb := newActEnv(t)
mine := actInsert(t, gdb, "mine", "acme")
const widget = "/acme/demo/gadgets/widgets/lookup"
const toolbar = "/acme/demo/gadgets/toolbar/recount"
t.Run("payload reaches the action and data comes back untouched", func(t *testing.T) {
const payload = `{"order":[3,1,2],"note":"x"}`
rec := env.expect(t, http.StatusOK, http.MethodPost, widget,
fmt.Sprintf(`{"record_id":%d,"values":{"name":"typed","tenant":"other"},"payload":%s}`, mine, payload), "bearer")
calls := env.spy.take()
if len(calls) != 1 {
t.Fatalf("calls = %+v", calls)
}
if string(calls[0].Payload) != payload {
t.Fatalf("payload = %s, want %s", calls[0].Payload, payload)
}
if !reflect.DeepEqual(calls[0].Values, map[string]any{"name": "typed"}) {
t.Fatalf("values = %#v", calls[0].Values)
}
result := actResult(t, rec)
if !reflect.DeepEqual(result.Fill, map[string]any{"name": "reordered"}) {
t.Fatalf("fill = %#v", result.Fill)
}
want := map[string]any{
"echo": map[string]any{"order": []any{float64(3), float64(1), float64(2)}, "note": "x"},
"items": []any{map[string]any{"id": float64(1), "title": "a"}},
}
if !reflect.DeepEqual(result.Data, want) {
t.Fatalf("data = %#v, want %#v", result.Data, want)
}
})
t.Run("payload may be any JSON value and is nil when absent", func(t *testing.T) {
for _, literal := range []string{`[1,2]`, `"s"`, `0`, `false`, `null`} {
env.expect(t, http.StatusOK, http.MethodPost, widget, `{"payload":`+literal+`}`, "bearer")
calls := env.spy.take()
if len(calls) != 1 || string(calls[0].Payload) != literal {
t.Fatalf("payload %s: calls = %+v", literal, calls)
}
}
env.expect(t, http.StatusOK, http.MethodPost, widget, `{}`, "bearer")
calls := env.spy.take()
if len(calls) != 1 || len(calls[0].Payload) != 0 || calls[0].Payload != nil {
t.Fatalf("absent payload: calls = %+v", calls)
}
})
t.Run("no data key when the action returns none", func(t *testing.T) {
for _, path := range []string{widget, toolbar} {
rec := env.expect(t, http.StatusOK, http.MethodPost, path, `{}`, "bearer")
var body struct {
Data map[string]any `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if _, ok := body.Data["data"]; ok {
t.Fatalf("%s carried a data key: %s", path, rec.Body.String())
}
}
env.spy.take()
})
t.Run("payload cap", func(t *testing.T) {
fits := `{"payload":"` + strings.Repeat("a", 65534) + `"}`
env.expect(t, http.StatusOK, http.MethodPost, widget, fits, "bearer")
if calls := env.spy.take(); len(calls) != 1 || len(calls[0].Payload) != 65536 {
t.Fatalf("64 KiB payload: calls = %d", len(calls))
}
over := `{"payload":"` + strings.Repeat("a", 65535) + `"}`
rec := env.expect(t, http.StatusUnprocessableEntity, http.MethodPost, widget, over, "bearer")
actErrorCode(t, rec.Body.Bytes(), "validation_failed")
if !strings.Contains(rec.Body.String(), `"body"`) {
t.Fatalf("422 without a body detail: %s", rec.Body.String())
}
if calls := env.spy.take(); len(calls) != 0 {
t.Fatalf("action ran for an oversized payload: %d calls", len(calls))
}
})
t.Run("toolbar and record routes refuse a payload", func(t *testing.T) {
for _, body := range []string{`{"payload":{}}`, `{"payload":null}`, `{"payload":1}`} {
rec := env.expect(t, http.StatusUnprocessableEntity, http.MethodPost, toolbar, body, "bearer")
actErrorCode(t, rec.Body.Bytes(), "validation_failed")
}
if calls := env.spy.take(); len(calls) != 0 {
t.Fatalf("toolbar ran with a payload: %+v", calls)
}
roster, rdb := newRosterEnv(t)
person := rosterInsert(t, rdb, rosterPerson{Tenant: "acme", Name: "Pat", Email: "pat@example.test"})
rec := roster.expect(t, http.StatusUnprocessableEntity, http.MethodPost, rosterPath(person, "/actions/activate"), `{"payload":1}`, "bearer")
actErrorCode(t, rec.Body.Bytes(), "validation_failed")
if rosterLoad(t, rdb, person).Active {
t.Fatal("a record action ran with a payload")
}
})
t.Run("data over 256 KiB or unencodable is an opaque 500", func(t *testing.T) {
for _, literal := range []string{`"big"`, `"nan"`} {
rec := env.expect(t, http.StatusInternalServerError, http.MethodPost, widget, `{"payload":`+literal+`}`, "bearer")
actErrorCode(t, rec.Body.Bytes(), "error")
if strings.Contains(rec.Body.String(), "xxxx") {
t.Fatalf("500 leaked the data: %d bytes", rec.Body.Len())
}
}
env.spy.take()
})
}

View File

@@ -106,8 +106,8 @@ func (p *Plugin) Schedule() []pact.ScheduledCommand {
| `pact.AdminRecordSource` | Supplies a new model record for the generic admin handlers. | | `pact.AdminRecordSource` | Supplies a new model record for the generic admin handlers. |
| `pact.AdminClientAssets` | Declares a controller's admin JS (`AdminJS`) and CSS (`AdminCSS`) files, paths under the plugin's `assets/` directory. | | `pact.AdminClientAssets` | Declares a controller's admin JS (`AdminJS`) and CSS (`AdminCSS`) files, paths under the plugin's `assets/` directory. |
| `pact.AdminAction` | One named controller action: name, label, extra permissions and the Go `Run` function. | | `pact.AdminAction` | One named controller action: name, label, extra permissions and the Go `Run` function. |
| `pact.AdminActionInput` | What an action receives: widget field, optional record id and scoped record, and the fill snapshot. | | `pact.AdminActionInput` | What an action receives: widget field, optional record id and scoped record, the fill snapshot, and the widget's raw JSON payload (`Payload`, at most 64 KiB, nil when absent, never set for a toolbar action, not inspected by the framework). |
| `pact.AdminActionResult` | What an action returns: a message for the toast and the fill write-back values. | | `pact.AdminActionResult` | What an action returns: a message for the toast, the fill write-back values, and an optional `Data` value passed through to the widget as `data` (any JSON up to 256 KiB encoded, not filtered like fill, omitted when nil). |
| `pact.HasAdminActions` | Registers a controller's actions for `toolbar.buttons` and `type: widget` fields. | | `pact.HasAdminActions` | Registers a controller's actions for `toolbar.buttons` and `type: widget` fields. |
| `pact.AdminBulkAction` | One named bulk action: name, label, optional confirm text, extra permissions and the Go `Run` function. | | `pact.AdminBulkAction` | One named bulk action: name, label, optional confirm text, extra permissions and the Go `Run` function. |
| `pact.AdminBulkActionInput` | What a bulk action receives: `Records`, the selected records loaded and row-locked through the list scope. | | `pact.AdminBulkActionInput` | What a bulk action receives: `Records`, the selected records loaded and row-locked through the list scope. |

View File

@@ -2,6 +2,7 @@ package pact
import ( import (
"context" "context"
"encoding/json"
"io/fs" "io/fs"
"net/http" "net/http"
"time" "time"
@@ -233,20 +234,33 @@ type AdminAction struct {
// record the framework loaded through the controller's FormExtendQuery scope, // record the framework loaded through the controller's FormExtendQuery scope,
// nil when RecordID is nil. Values is the widget's snapshot of its fill // nil when RecordID is nil. Values is the widget's snapshot of its fill
// fields, already reduced to the field's declared fill keys and to scalars. // fields, already reduced to the field's declared fill keys and to scalars.
// Payload is the widget's own payload: the `summer-action` event's
// detail.payload, posted by the SPA as the body key `payload`. It is 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: an action decodes it itself with encoding/json and
// validates any ids it contains against its own scope.
type AdminActionInput struct { type AdminActionInput struct {
Field string Field string
RecordID *uint64 RecordID *uint64
Record any Record any
Values map[string]any Values map[string]any
Payload json.RawMessage
} }
// AdminActionResult is an action's answer. Message is a phrase key or text, // AdminActionResult is an action's answer. Message is a phrase key or text,
// localized by the framework and shown as a toast. Fill is the widget // localized by the framework and shown as a toast. Fill is the widget
// write-back; keys outside the field's declared fill keys and non-scalar // write-back; keys outside the field's declared fill keys and non-scalar
// values are dropped before the response is written. // values are dropped before the response is written. Data is structured data
// for the widget: it is written to the response as `data` exactly as it
// encodes (any JSON value, capped at 256 KiB encoded; a larger or unencodable
// value is a 500), is not subject to the fill allowlist, and is omitted when
// nil. The SPA sets it on the element as the `data` attribute and announces it
// with a `summer-result` event.
type AdminActionResult struct { type AdminActionResult struct {
Message string Message string
Fill map[string]any Fill map[string]any
Data any
} }
// HasAdminActions is implemented by an admin controller that registers named // HasAdminActions is implemented by an admin controller that registers named