feat(10.1-01): serve controller JS/CSS and run registered toolbar actions

- boardwalk exports ContentType and SetSecurityHeaders
- pact.AdminClientAssets files are read and hashed at boot and served by exact
  key under {prefix}/assets/{vendor}/{plugin}/ with nosniff, CSP, CORP,
  no-cache and an ETag; a miss falls through to the SPA
- list and form schemas carry assets URLs with a ?v= hash
- toolbar.buttons resolves create, delete and registered actions after decode;
  toolbarActions is permission-filtered per admin
- POST .../toolbar/{action} behind requireAjax and action permissions
This commit is contained in:
Jakub Zych
2026-09-28 23:41:17 +02:00
parent f9281949a6
commit 8b1cb244de
23 changed files with 772 additions and 64 deletions

View File

@@ -170,6 +170,27 @@
],
"type": "object"
},
"cabana.ControllerAssets": {
"properties": {
"scripts": {
"items": {
"type": "string"
},
"type": "array"
},
"styles": {
"items": {
"type": "string"
},
"type": "array"
}
},
"required": [
"scripts",
"styles"
],
"type": "object"
},
"cabana.Envelope-array_cabana_FilterOption": {
"properties": {
"data": {
@@ -609,6 +630,14 @@
},
"cabana.FormView": {
"properties": {
"assets": {
"allOf": [
{
"$ref": "#/components/schemas/cabana.ControllerAssets"
}
],
"description": "Assets are the controller's plugin JS and CSS URLs; a settings form\ncarries empty lists."
},
"fields": {
"items": {
"$ref": "#/components/schemas/cabana.FormField"
@@ -642,6 +671,7 @@
}
},
"required": [
"assets",
"fields",
"messages",
"meta",
@@ -838,6 +868,14 @@
},
"cabana.ListSchema": {
"properties": {
"assets": {
"allOf": [
{
"$ref": "#/components/schemas/cabana.ControllerAssets"
}
],
"description": "Assets are the controller's plugin JS and CSS URLs (pact.AdminClientAssets)."
},
"bulkActions": {
"items": {
"$ref": "#/components/schemas/cabana.BulkAction"
@@ -915,6 +953,13 @@
"title": {
"type": "string"
},
"toolbarActions": {
"description": "ToolbarActions are the controller-registered toolbar.buttons entries\nthe requesting admin may run, in declared order, with their labels.",
"items": {
"$ref": "#/components/schemas/cabana.ToolbarAction"
},
"type": "array"
},
"toolbarButtons": {
"items": {
"type": "string"
@@ -923,6 +968,7 @@
}
},
"required": [
"assets",
"bulkActions",
"columns",
"filters",
@@ -935,6 +981,7 @@
"showSearch",
"showSetup",
"showSorting",
"toolbarActions",
"toolbarButtons"
],
"type": "object"
@@ -1277,6 +1324,21 @@
},
"type": "object"
},
"cabana.ToolbarAction": {
"properties": {
"label": {
"type": "string"
},
"name": {
"type": "string"
}
},
"required": [
"label",
"name"
],
"type": "object"
},
"cabana.fieldContext": {
"oneOf": [
{
@@ -2730,6 +2792,121 @@
]
}
},
"/{vendor}/{plugin}/{controller}/toolbar/{action}": {
"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.",
"parameters": [
{
"description": "Vendor",
"in": "path",
"name": "vendor",
"required": true,
"schema": {
"type": "string"
}
},
{
"description": "Plugin",
"in": "path",
"name": "plugin",
"required": true,
"schema": {
"type": "string"
}
},
{
"description": "Controller",
"in": "path",
"name": "controller",
"required": true,
"schema": {
"type": "string"
}
},
{
"description": "Action name",
"in": "path",
"name": "action",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.AdminActionRequest"
}
}
},
"description": "Empty object",
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.Envelope-cabana_AdminActionResult"
}
}
},
"description": "OK"
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
}
}
},
"description": "Unauthorized"
},
"403": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
}
}
},
"description": "Forbidden"
},
"404": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
}
}
},
"description": "Not Found"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
}
}
},
"description": "Unprocessable Entity"
}
},
"security": [
{
"BackendBearer": []
}
],
"summary": "Run a toolbar action",
"tags": [
"admin"
]
}
},
"/{vendor}/{plugin}/{controller}/widgets/{field}": {
"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.",

View File

@@ -1237,6 +1237,95 @@ export interface paths {
patch?: never;
trace?: never;
};
"/{vendor}/{plugin}/{controller}/toolbar/{action}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* 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.
*/
post: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Vendor */
vendor: string;
/** @description Plugin */
plugin: string;
/** @description Controller */
controller: string;
/** @description Action name */
action: string;
};
cookie?: never;
};
/** @description Empty object */
requestBody: {
content: {
"application/json": components["schemas"]["cabana.AdminActionRequest"];
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.Envelope-cabana_AdminActionResult"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
/** @description Not Found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
/** @description Unprocessable Entity */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
};
};
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/{vendor}/{plugin}/{controller}/widgets/{field}": {
parameters: {
query?: never;
@@ -1953,6 +2042,10 @@ export interface components {
"cabana.BulkResult": {
deleted: number;
};
"cabana.ControllerAssets": {
scripts: string[];
styles: string[];
};
"cabana.Envelope-array_cabana_FilterOption": {
data: components["schemas"]["cabana.FilterOption"][];
meta: components["schemas"]["cabana.SuccessMeta"];
@@ -2076,6 +2169,11 @@ export interface components {
update: components["schemas"]["cabana.FormRedirect"];
};
"cabana.FormView": {
/**
* @description Assets are the controller's plugin JS and CSS URLs; a settings form
* carries empty lists.
*/
assets: components["schemas"]["cabana.ControllerAssets"];
fields: components["schemas"]["cabana.FormField"][];
/** @description Messages is the form's copy resolved in the request locale (D-13). */
messages: components["schemas"]["cabana.FormMessages"];
@@ -2141,6 +2239,8 @@ export interface components {
total: number;
};
"cabana.ListSchema": {
/** @description Assets are the controller's plugin JS and CSS URLs (pact.AdminClientAssets). */
assets: components["schemas"]["cabana.ControllerAssets"];
bulkActions: components["schemas"]["cabana.BulkAction"][];
columns: components["schemas"]["cabana.ListColumn"][];
defaultSort?: components["schemas"]["cabana.ListSort"];
@@ -2164,6 +2264,11 @@ export interface components {
showSetup: boolean;
showSorting: boolean;
title?: string;
/**
* @description ToolbarActions are the controller-registered toolbar.buttons entries
* the requesting admin may run, in declared order, with their labels.
*/
toolbarActions: components["schemas"]["cabana.ToolbarAction"][];
toolbarButtons: string[];
};
"cabana.ListSort": {
@@ -2261,6 +2366,10 @@ export interface components {
per_page?: number;
total?: number;
};
"cabana.ToolbarAction": {
label: string;
name: string;
};
"cabana.fieldContext": string | string[];
"cabana.jsonScalar": (string | number | boolean) | null;
};

View File

@@ -75,6 +75,10 @@
"meta": {
"locale": "en"
},
"assets": {
"scripts": [],
"styles": []
},
"redirects": {
"create": {
"redirect": "",

View File

@@ -143,6 +143,10 @@
"meta": {
"locale": "en"
},
"assets": {
"scripts": [],
"styles": []
},
"redirects": {
"create": {
"redirect": "acme/demo/widgets/update/:id",

View File

@@ -10,6 +10,8 @@
"searchTerm": "",
"recordUrl": "acme/demo/widgets/update/:id",
"toolbarButtons": ["create", "delete"],
"toolbarActions": [],
"assets": { "scripts": [], "styles": [] },
"columns": [
{ "key": "name", "label": "Name", "searchable": true, "sortable": true },
{ "key": "code", "label": "Code", "searchable": true, "sortable": true },