feat(11.1-04): add the Backend section, the remaining Services pages and the concept map links

- docs/backend: admin controllers, forms, lists and filters, relation
  manager, users and permissions, settings, partials and widgets, admin SPA
- docs/services: storage, outbound HTTP, realtime, Web Push, search, parity
  testing and the Frontend and AJAX (not provided) page
- Examples for cabana (with testdata/docs YAML), fetchguard, lighthouse and
  its centrifugo driver, flare, beachcomber and typesense, tide; lighthouse
  and beachcomber TestDocs* regions run on their Postgres harnesses
- concept map rows link their guide pages and the not-provided rows the
  Frontend and AJAX page; index lists Backend, Database and Services
- TestDocsRequiredPages asserts the D-08 section order
This commit is contained in:
Jakub Zych
2026-09-30 23:18:35 +02:00
parent f7dfe68707
commit 44bd1446f5
40 changed files with 2882 additions and 30 deletions

View File

@@ -0,0 +1,224 @@
---
title: Admin controllers
description: Declare admin controllers with pact.AdminController and WinterCMS-shaped YAML, and let the generic JSON admin API list, show, create, update and delete records.
section: backend
order: 10
---
# Admin controllers
A WinterCMS backend controller extends `Backend\Classes\Controller`, implements the List, Form and Relation behaviours, and describes its screens in `config_list.yaml`, `config_form.yaml` and the model's `columns.yaml` and `fields.yaml`. SummerCMS keeps the YAML, and drops the controller class: [cabana](../../modules/cabana/README.md) compiles the YAML at boot and serves one generic JSON admin API for every controller, and the admin SPA renders the screens from the compiled schemas.
## Declaring a controller
A controller is a small Go type that implements `pact.AdminController`: its ID, the model name that `modelClass` in the YAML must match, and the directory that holds its YAML. It usually also implements `pact.AdminRecordSource`, which returns the GORM model to query, and `pact.AdminPermissioned`, the permissions an administrator needs. The plugin returns its controllers from `pact.HasAdminControllers` and its embedded YAML tree from `pact.AdminAssets`:
```go src=modules/cabana/example_controller_test.go
package cabana_test
import (
"io/fs"
"os"
"time"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/pact"
)
// Post is the model behind the acme.blog posts controller.
type Post struct {
ID uint `gorm:"column:id;primaryKey"`
Title string `gorm:"column:title"`
Slug string `gorm:"column:slug"`
Status string `gorm:"column:status"`
Published bool `gorm:"column:published"`
PublishedAt *time.Time `gorm:"column:published_at"`
Content string `gorm:"column:content"`
}
func (Post) TableName() string { return "acme_blog_posts" }
// Fillable lists the columns the admin form may write.
func (Post) Fillable() []string {
return []string{"title", "slug", "status", "published", "content"}
}
// PostsController is the admin controller for posts: the Go form of a
// WinterCMS controller with the List and Form behaviours.
type PostsController struct{}
var (
_ pact.AdminController = PostsController{}
_ pact.AdminRecordSource = PostsController{}
_ pact.AdminPermissioned = PostsController{}
_ pact.HasAdminControllers = (*BlogPlugin)(nil)
)
// ID is the controller ID; its admin API path is /acme/blog/posts.
func (PostsController) ID() string { return "acme.blog.posts" }
// ModelName must equal modelClass in the YAML.
func (PostsController) ModelName() string { return "Post" }
// ConfigDir holds config_list.yaml, config_form.yaml and config_filter.yaml.
func (PostsController) ConfigDir() string { return "controllers/posts" }
// NewRecord returns the model the generic admin handlers query.
func (PostsController) NewRecord() any { return &Post{} }
// RequiredPermissions are checked before any schema or query.
func (PostsController) RequiredPermissions() []string {
return []string{"acme.blog.access_posts"}
}
// BlogSettings is the singleton row behind the plugin's settings page.
type BlogSettings struct {
ID uint `gorm:"column:id;primaryKey"`
PostsPerPage int `gorm:"column:posts_per_page"`
CommentsEnabled bool `gorm:"column:comments_enabled"`
}
func (BlogSettings) TableName() string { return "acme_blog_settings" }
func (BlogSettings) Fillable() []string { return []string{"posts_per_page", "comments_enabled"} }
// Rules validates a settings save, as a WinterCMS settings model's $rules.
func (BlogSettings) Rules() map[string]string {
return map[string]string{"posts_per_page": "required|integer|between:1,100"}
}
// BlogPlugin is the acme.blog plugin; only its backend surface is shown.
type BlogPlugin struct{}
var (
_ pact.AdminAssets = (*BlogPlugin)(nil)
_ pact.HasPermissions = (*BlogPlugin)(nil)
_ pact.HasNavigation = (*BlogPlugin)(nil)
_ pact.HasSettings = (*BlogPlugin)(nil)
)
func (p *BlogPlugin) ID() string { return "acme.blog" }
func (p *BlogPlugin) Requires() []string { return nil }
func (p *BlogPlugin) Register(app *backpack.App) error { return nil }
func (p *BlogPlugin) Boot(app *backpack.App) error { return nil }
// AdminControllers registers the plugin's admin controllers.
func (p *BlogPlugin) AdminControllers() []pact.AdminController {
return []pact.AdminController{PostsController{}}
}
// AdminFS is the plugin's embedded controllers/ and models/ tree. A real
// plugin returns an embed.FS; the example reads the same files from testdata.
func (p *BlogPlugin) AdminFS() fs.FS { return os.DirFS("testdata/docs") }
// Permissions replaces registerPermissions().
func (p *BlogPlugin) Permissions() []pact.Permission {
return []pact.Permission{
{Code: "acme.blog.access_posts", Tab: "acme.blog::lang.plugin.name", Label: "acme.blog::lang.permissions.posts"},
{Code: "acme.blog.access_settings", Tab: "acme.blog::lang.plugin.name", Label: "acme.blog::lang.permissions.settings"},
}
}
// Navigation replaces registerNavigation().
func (p *BlogPlugin) Navigation() []pact.NavigationItem {
return []pact.NavigationItem{{
Code: "blog",
Label: "acme.blog::lang.plugin.name",
Icon: "icon-pencil",
Permissions: []string{"acme.blog.access_posts"},
Controller: "acme.blog.posts",
SideMenu: []pact.NavigationItem{
{Code: "posts", Label: "acme.blog::lang.posts.title", Controller: "acme.blog.posts"},
},
}}
}
// Settings replaces registerSettings().
func (p *BlogPlugin) Settings() []pact.SettingsItem {
return []pact.SettingsItem{{
Code: "blog",
Label: "acme.blog::lang.settings.label",
Description: "acme.blog::lang.settings.description",
Category: "acme.blog::lang.plugin.name",
Icon: "icon-pencil",
Model: "BlogSettings",
Permissions: []string{"acme.blog.access_settings"},
Form: "models/settings/fields.yaml",
NewModel: func() any { return &BlogSettings{} },
}}
}
```
`summer make:admin-controller` writes the controller type and its four YAML files:
```sh
summer make:admin-controller acme.blog Posts
```
The controller ID maps to the admin API path: `acme.blog.posts` is served under `<prefix>/api/v1/acme/blog/posts`. The admin prefix is `backend.uri`, `/backend` by default. A model's `Fillable` method decides which form fields the API may write; see [Forms](forms.md).
## Compilation at boot
At start-up `cabana.Activate` compiles every plugin's controllers, settings, navigation and permissions once. Any schema mistake stops the start-up with an error that names the plugin, the controller and the file: an unknown YAML key, a `modelClass` that does not match `pact.AdminController.ModelName`, a list column the model does not have, an unknown field type. Nothing is parsed per request. The example below activates the admin for the plugin above:
```go src=modules/cabana/example_test.go#ExampleActivate
cfg, err := compass.Open(compass.Options{Dir: "config", Env: "development", Environ: []string{}})
if err != nil {
fmt.Println(err)
return
}
// Set through SUMMER_ADMIN__JWT__SECRET in a real deployment.
_ = cfg.Set("admin.jwt.secret", "test-only-secret-with-at-least-32-bytes")
_ = cfg.Set("backend.uri", "/admin")
routes, err := cabana.Activate(backpack.New(cfg), []party.Plugin{&BlogPlugin{}})
if err != nil {
fmt.Println(err)
return
}
fmt.Println(routes.Prefix)
editor := &bouncer.Principal{ID: 7, Backend: true, PermissionGrants: map[string]bool{"acme.blog.*": true}}
fmt.Println(cabana.Allows(editor, PostsController{}.RequiredPermissions()))
fmt.Println(cabana.Allows(editor, []string{"acme.shop.access_orders"}))
// Output:
// /admin
// true
// false
```
`admin.jwt.secret` is required as soon as any plugin registers an admin controller. With no admin controllers, no admin routes exist and no secret is needed.
## The admin API
Every controller gets the same routes, relative to `<prefix>/api/v1/{vendor}/{plugin}/{controller}`:
| Method and path | Does |
|-----------------|------|
| `GET /schema/list`, `GET /schema/form` | The list and form schemas, translated into the request locale. |
| `GET /` | Lists records with search, sort, filters and pagination, allowed only on columns the schema declares. |
| `POST /` | Creates a record. |
| `GET /{id}`, `PUT /{id}`, `DELETE /{id}` | Shows, updates and deletes a record. |
| `POST /bulk-delete` | Deletes a set of records in one transaction. |
The cabana README lists the full route table, including relation, options, widget, toolbar and partial routes. Every response uses one JSON envelope (`cabana.Envelope`), and a validation failure is a 422 `validation_failed` error with messages per field.
Request parameters never reach SQL directly: search, sort and filters apply only to declared columns, and a write passes only the form's writable fields, filled through `lagoon.Fill` and validated through `lagoon.Validate` in a transaction.
## Hooks
Behaviour overrides such as `formBeforeCreate` or `listExtendQuery` become optional interfaces on the controller. cabana checks for each one and calls it at the matching point:
| Interface | Runs |
|-----------|------|
| `pact.ListExtendQuery` | Scopes every list query, for example to the administrator's own records. |
| `pact.FormExtendQuery` | Scopes every show, update and delete lookup, so a record outside the scope is a 404. |
| `pact.FormBeforeCreate`, `pact.FormAfterCreate` | Around a create, inside its transaction. |
| `pact.FormBeforeUpdate`, `pact.FormAfterUpdate` | Around an update, inside its transaction. |
| `pact.FormBeforeDelete`, `pact.FormAfterDelete` | Around a delete, inside its transaction. |
| `pact.DropdownOptionsProvider` | Supplies the options of a `dropdown` field that names a method. |
Scope reads and writes with `pact.ListExtendQuery` and `pact.FormExtendQuery` rather than checking in a hook: the scope then applies to every route, including relation and action routes.
## Toolbar actions
`toolbar.buttons` in `config_list.yaml` lists the built-in `create` and `delete` and any action the controller registers through `pact.HasAdminActions`. See [Partials and widgets](partials-and-widgets.md) for actions and the rest of the extension points.

38
docs/backend/admin-spa.md Normal file
View File

@@ -0,0 +1,38 @@
---
title: Admin SPA
description: How boardwalk serves the embedded Vue admin SPA under backend.uri, how the SPA talks to the admin API, and how its TypeScript types come from OpenAPI.
section: backend
order: 80
---
# Admin SPA
WinterCMS renders its backend on the server with layouts, partials and the AJAX framework. SummerCMS replaces that with one Vue 3 single-page app, built once and embedded in the binary by [boardwalk](../../modules/boardwalk/README.md). Plugins do not ship admin pages: they ship YAML and, when they need them, partials and scripts, and the SPA renders every controller from the schemas the admin API serves.
## Serving
cabana mounts the SPA under the admin prefix, `backend.uri` (`/backend` by default; one or more lowercase path segments). `boardwalk.Handler` serves the build:
- Any path under the prefix that is not a file and not under `api/` returns `index.html`, so the SPA's own routes work on reload.
- Paths under `api/` that no API route matches return the admin API's JSON `not_found` error, never the SPA.
- Hashed files under `assets/` are cached for a long time; `index.html` is never cached.
- Every response carries a restrictive Content-Security-Policy, frame denial, `nosniff`, a same-origin referrer policy and `noindex, nofollow`.
The build is path-agnostic: `index.html` holds a placeholder (`boardwalk.BaseToken`) that `boardwalk.RewriteIndex` replaces with the prefix once, when the handler is built. A build without the placeholder fails the start-up.
A plugin route under the admin prefix also fails the start-up: the SPA and the admin API own that whole path.
## How the SPA talks to the server
The SPA signs in through the admin API and keeps the token in the HttpOnly cookie described on [Users and permissions](users-and-permissions.md). For each screen it loads the controller's localized schema (`schema/list`, `schema/form`), then the records, and renders the fields and columns the schema names. Strings come from `GET <prefix>/api/v1/lang`, the `backend::lang` bundle in the request locale, with CLDR plural forms.
## 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:
```sh
scripts/check-admin-openapi.sh --check
```
## Building the SPA
The SPA's source is the `admin/` Vite project. `npm --prefix admin run build` type-checks it and writes the build to `modules/boardwalk/dist`, which the next `go build` embeds. An application that only uses the framework never builds the SPA: the build is committed with the framework.

105
docs/backend/forms.md Normal file
View File

@@ -0,0 +1,105 @@
---
title: Forms
description: Describe admin forms in config_form.yaml and fields.yaml, with the supported field types, spans, tabs, dropdown options and create or update contexts.
section: backend
order: 20
---
# Forms
The Form behaviour's `config_form.yaml` and the model's `fields.yaml` keep their WinterCMS shape. [cabana](../../modules/cabana/README.md) compiles them strictly at boot: an unknown key, field type, span or size stops the start-up with an error naming the file and the field, so a WinterCMS option that SummerCMS does not implement is never ignored silently.
## config_form.yaml
The controller's form configuration names the fields file with a WinterCMS path, the model class, and where the SPA goes after a save:
```yaml src=modules/cabana/testdata/docs/controllers/posts/config_form.yaml
name: acme.blog::lang.posts.form
form: ~/plugins/acme/blog/models/post/fields.yaml
modelClass: Post
defaultRedirect: acme/blog/posts
create:
redirect: acme/blog/posts/update/:id
redirectClose: acme/blog/posts
update:
redirect: acme/blog/posts
redirectClose: acme/blog/posts
```
`modelClass` must equal the controller's `pact.AdminController.ModelName`. The `~/plugins/<vendor>/<plugin>/` prefix points into the plugin's own embedded tree.
## fields.yaml
```yaml src=modules/cabana/testdata/docs/models/post/fields.yaml
fields:
title:
label: acme.blog::lang.posts.title_column
type: text
span: left
required: true
slug:
label: acme.blog::lang.posts.slug
type: text
span: right
context: update
comment: acme.blog::lang.posts.slug_comment
status:
label: acme.blog::lang.posts.status
type: dropdown
span: left
options:
draft: acme.blog::lang.posts.draft
published: acme.blog::lang.posts.published
published:
label: acme.blog::lang.posts.published
type: switch
span: right
content:
label: acme.blog::lang.posts.content
type: textarea
size: large
tab: acme.blog::lang.posts.tab_content
```
The compiled schema keeps the fields in file order, with their labels as translation keys until a request asks for them in its locale:
```go src=modules/cabana/example_test.go#ExampleCompileForm
fsys := os.DirFS("testdata/docs")
form, err := cabana.CompileForm("acme.blog", PostsController{}, fsys)
if err != nil {
fmt.Println(err)
return
}
for _, f := range form.Fields {
fmt.Printf("%s %s span=%q tab=%q required=%v options=%d\n", f.Name, f.Type, f.Span, f.Tab, f.Required, len(f.Options))
}
// Output:
// title text span="left" tab="" required=true options=0
// slug text span="right" tab="" required=false options=0
// status dropdown span="left" tab="" required=false options=2
// published switch span="right" tab="" required=false options=0
// content textarea span="" tab="acme.blog::lang.posts.tab_content" required=false options=0
```
### Field types
| Type | Renders |
|------|---------|
| `text`, `textarea`, `number` | Text inputs. |
| `checkbox`, `switch` | Booleans. |
| `dropdown` | A select. Options are a map in the YAML, or the name of a method the controller answers through `pact.DropdownOptionsProvider`. |
| `relation` | A belongsTo or belongsToMany picker; see [Relation manager](relation-manager.md). |
| `relation-manager` | An embedded list of related records; see [Relation manager](relation-manager.md). |
| `widget` | A plugin custom element with a server action; see [Partials and widgets](partials-and-widgets.md). |
| `partial` | A server-rendered template; see [Partials and widgets](partials-and-widgets.md). |
The WinterCMS widgets that are not in this list (the rich editor, the media finder, the repeater, the file upload and the others) are not provided. A field with one of those types stops the start-up.
### Field options
A field takes `label`, `comment`, `type`, `required`, `default`, `tab`, `span` (`left`, `right`, `full`, `auto`, `row`), `size` (`tiny`, `small`, `large`, `huge`, `giant`), `context`, `attributes` (scalar HTML attributes for the input), `options` and `emptyOption`, plus `nameFrom` and `relation` on relation fields. WinterCMS keys outside this set, such as `readOnly`, `disabled`, `trigger` or `dependsOn`, are refused.
`context: update` shows a field only on the update form, and `context: create` only on the create form; a list of contexts is also accepted. The context is enforced on the server too: a field that is hidden on a form is never written by that form's save, whatever the request body holds.
## What a save may write
The form's writable fields are bound to model columns at boot. A save passes only those fields that are also in the model's `Fillable` list, drops unknown keys, case variants and nested objects, and fills the model with `lagoon.Fill` (see [Models](../database/models.md)). The model's validation rules (a `Rules` method returning `lagoon.Validate` rule strings) and the form's `required` flags are checked in the save's transaction, and a failure is a 422 with messages per field. A value that does not fit its column is also a 422 on that field.

View File

@@ -0,0 +1,114 @@
---
title: Lists and filters
description: Describe admin lists in config_list.yaml and columns.yaml, with search, sorting, pagination options and switch, date range and scope filters.
section: backend
order: 30
---
# Lists and filters
The List behaviour's `config_list.yaml`, the model's `columns.yaml` and the Filter widget's `config_filter.yaml` keep their WinterCMS shape. [cabana](../../modules/cabana/README.md) compiles them at boot and runs every list query itself, so search, sort and filter parameters from the request apply only to what the YAML declares.
## config_list.yaml
```yaml src=modules/cabana/testdata/docs/controllers/posts/config_list.yaml
list: ~/plugins/acme/blog/models/post/columns.yaml
modelClass: Post
title: acme.blog::lang.posts.title
recordUrl: acme/blog/posts/update/:id
recordsPerPage: 20
perPageOptions: [20, 50, 100]
showCheckboxes: true
defaultSort:
column: published_at
direction: desc
filter: config_filter.yaml
toolbar:
buttons: [create, delete]
search:
prompt: backend::lang.list.search_prompt
```
| Key | Sets |
|-----|------|
| `list` | The columns file, with a WinterCMS `~/plugins/...` path into the plugin. |
| `modelClass` | Must equal the controller's model name. |
| `title`, `noRecordsMessage` | Translation keys shown by the SPA. |
| `recordUrl` | Where a click on a row goes; `:id` is replaced. |
| `recordsPerPage`, `perPageOptions` | The page size and the sizes a user may pick. |
| `showCheckboxes`, `showSetup`, `showSorting`, `showSearch` | Which list controls appear. |
| `defaultSort` | `column` and `direction` of the initial order. |
| `toolbar` | `buttons` (the built-in `create` and `delete` and registered actions) and `search.prompt`. |
| `filter` | The filter file, relative to the controller's directory. |
| `headerPartial` | A server-rendered strip above the list; see [Partials and widgets](partials-and-widgets.md). |
## columns.yaml
```yaml src=modules/cabana/testdata/docs/models/post/columns.yaml
columns:
title:
label: acme.blog::lang.posts.title_column
searchable: true
published:
label: acme.blog::lang.posts.published
type: switch
published_at:
label: acme.blog::lang.posts.published_at
type: datetime
```
A column takes `label`, `type` (`text`, `datetime` or `switch`), `searchable` (default false) and `sortable` (default true, as in WinterCMS), and, for a column read through a relation, `relation` and `select`. Other WinterCMS column types and options are refused at boot. The compiled list keeps the columns in file order:
```go src=modules/cabana/example_test.go#ExampleCompileList
// The plugin embeds its controllers/ and models/ trees; the example reads
// the same files from testdata.
fsys := os.DirFS("testdata/docs")
list, err := cabana.CompileList("acme.blog", PostsController{}, fsys)
if err != nil {
fmt.Println(err)
return
}
fmt.Println(list.Title, list.RecordsPerPage, list.PerPageOptions, list.ToolbarButtons)
for _, c := range list.Columns {
fmt.Printf("column %s type=%q searchable=%v sortable=%v\n", c.Key, c.Type, c.Searchable, c.Sortable)
}
for _, f := range list.Filters {
fmt.Printf("filter %s type=%s column=%s\n", f.Name, f.Type, f.Column)
}
// Output:
// acme.blog::lang.posts.title 20 [20 50 100] [create delete]
// column title type="" searchable=true sortable=true
// column published type="switch" searchable=false sortable=true
// column published_at type="datetime" searchable=false sortable=true
// filter published type=switch column=published
// filter published_at type=daterange column=published_at
```
Search runs over the searchable columns only; sort accepts only sortable columns. A request that names any other column is refused, never passed to SQL.
## Filters
`config_filter.yaml` lists scopes. Three filter types are supported:
```yaml src=modules/cabana/testdata/docs/controllers/posts/config_filter.yaml
scopes:
published:
label: acme.blog::lang.posts.published
type: switch
column: published
published_at:
label: acme.blog::lang.posts.published_at
type: daterange
column: published_at
```
| Type | Filters |
|------|---------|
| `switch` | A boolean `column`. With `options`, the two values are the options' keys, kept as typed scalars. |
| `daterange` | A date or timestamp `column` between two dates. |
| scope (a `scope` key with `modelClass` and `nameFrom`) | Records by a model-backed choice. The model implements `pact.FilterScope`, whose `FilterScopes` lists the scope names it answers, and `pact.FilterOptions` for the choices the SPA loads. |
WinterCMS `conditions` SQL fragments are not supported; use a `column` or a scope the model implements. A scope filter whose name the model does not list in `FilterScopes` stops the start-up, so request text can never select another method.
## Scoping every list
To restrict which records an administrator sees at all, implement `pact.ListExtendQuery` on the controller. It receives the list query before search, filters and pagination are applied, so the restriction holds for every request. See [Admin controllers](admin-controllers.md) for the other hooks.

View File

@@ -0,0 +1,63 @@
---
title: Partials and widgets
description: Extend admin screens with server-rendered partials, plugin JavaScript and CSS, form widgets backed by server actions, and custom toolbar buttons.
section: backend
order: 70
---
# Partials and widgets
WinterCMS controllers extend their screens with partials, `addJs` and `addCss`, custom form widgets and toolbar buttons that call AJAX handlers. The SummerCMS admin is a single-page app, so [cabana](../../modules/cabana/README.md) keeps these extension points in a form the SPA can render safely: partials arrive as an allowlisted node tree, plugin scripts are declared files, and every button or widget runs a server action through a cabana-owned route.
## Server-rendered partials
Two places accept a partial:
- `headerPartial: <name>` in `config_list.yaml`, a strip above the list;
- a `type: partial` field with `path: <name>` in `fields.yaml`.
Both render `{ConfigDir}/_<name>.htm` with Go's `html/template`. WinterCMS `$/` and `~/` partial paths are not supported. The template's data is `.Data`, the value the controller's `pact.AdminPartialData` returns for that partial name; for a form partial on an existing record, cabana passes the record it loaded through the controller's `pact.FormExtendQuery` scope. `trans "<key>"` translates a phrase key in the request locale.
A statistics strip above a list, using the SPA's partial style classes:
```html src=modules/cabana/testdata/extension/controllers/gadgets/_stats.htm
<dl class="summer-stats">
{{- range .Data.Items -}}
<div class="summer-stat"><dt class="summer-stat__label">{{ trans .Label }}</dt><dd class="summer-stat__value">{{ .Count }}</dd></div>
{{- end -}}
</dl>
```
The view model must be a struct built for the template. cabana refuses a view model that holds the controller's model or any other GORM model, anywhere inside it, and refuses pre-escaped `html/template` content types, so every record value stays escaped.
The rendered HTML is parsed and walked through an allowlist before it reaches the SPA: script, style, iframe, form and similar elements are removed with their content, unknown elements are unwrapped, `id`, `style` and event handler attributes are dropped, and links and images must be same-origin paths. Output is capped at 64 KiB, 2000 nodes and a depth of 32. The cabana README lists the allowed elements, attributes and style classes.
## Plugin JavaScript and CSS
A controller that implements `pact.AdminClientAssets` names `.js`, `.mjs` and `.css` files under its plugin's `assets/` directory, the Go form of `addJs` and `addCss`. They are read from the embedded tree at start-up (a missing file stops it) and served from `<prefix>/assets/{vendor}/{plugin}/...` with a content hash in the URL, the admin Content-Security-Policy (`script-src 'self'`) and `nosniff`. Only declared files are reachable; the YAML and templates never are.
Plugin CSS may use only the SPA's public CSS variables (`--c-bg`, `--c-surface`, `--c-text`, `--c-primary` and the others the cabana README lists), which switch with dark mode. Do not hardcode colours and do not rely on the SPA's utility classes.
## Form widgets
A `type: widget` field puts a plugin custom element in the form and connects it to a server action:
```yaml
lookup:
label: acme.blog::lang.posts.lookup
type: widget
widget: acme-blog-lookup
action: lookup
fill: [title, slug]
```
- `widget` is the custom element's tag, which must start with the plugin's `{vendor}-{plugin}-` prefix. A plugin script declared through `pact.AdminClientAssets` defines the element.
- `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.
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.
## Toolbar actions
Names in `toolbar.buttons` of `config_list.yaml`, other than the built-in `create` and `delete`, are actions the controller registers through `pact.HasAdminActions`. Each needs a label. A toolbar action runs with an empty body and no record IDs, so it can never become an unscoped lookup of IDs the client chose. The list schema lists only the actions the administrator may run.
An unknown action name, a widget tag outside the plugin's prefix or a fill key that is not a writable scalar field stops the start-up.

View File

@@ -0,0 +1,56 @@
---
title: Relation manager
description: Edit belongsTo and belongsToMany relations in admin forms and manage linked records with config_relation.yaml, bound to models the controller names.
section: backend
order: 40
---
# Relation manager
WinterCMS edits relations in two ways: a `relation` form field that picks the related record, and the Relation behaviour, which embeds a list of linked records with link and unlink buttons. [cabana](../../modules/cabana/README.md) has both. The one rule that differs from WinterCMS: the framework never guesses a table, pivot or foreign key name. The controller supplies every name, and a missing or wrong one stops the start-up.
## Relation fields
A `type: relation` field in `fields.yaml` picks a belongsTo record or a set of belongsToMany records. `nameFrom` names the related model's label column:
```yaml
category:
label: acme.blog::lang.posts.category
type: relation
nameFrom: name
```
The controller implements `cabana.FieldRelationProvider` and returns a `cabana.FieldRelationContract` per field: its `Kind` (`belongsTo` or `belongsToMany`), a factory for the related model, and the `ForeignKey` of a belongsTo or the pivot model and its two key columns for a belongsToMany. An optional `OrderColumn` on the pivot stores the order in which the administrator picked the records.
The SPA loads the choices, paginated, from `.../fields/{field}/options`, and every record response carries the display labels of the linked records. A controller that implements `pact.RelationExtendOptionsQuery` narrows the choices, and the same scoped query rechecks the submitted IDs on save, so a record it does not offer cannot be attached.
## Relation managers
A `type: relation-manager` field embeds a relation manager in the form. Its `relation` key names an entry in the controller's `config_relation.yaml`, which describes the two panels: the linked records (`view`) and the candidates shown when linking (`manage`):
```yaml
editors:
label: acme.blog::lang.posts.editors
view:
list:
columns:
name:
label: acme.blog::lang.editors.name
email:
label: acme.blog::lang.editors.email
toolbarButtons: link|unlink
showSearch: true
manage:
list:
columns:
name:
label: acme.blog::lang.editors.name
showSearch: true
```
The controller implements `cabana.AdminRelationContractProvider` and returns a `cabana.RelationContract` per relation: the related and pivot model factories, the pivot's two foreign keys, a map from column names in the YAML to physical columns, and optionally the pivot columns a hook may set and a function that excludes candidate IDs, such as the parent itself. A relation in the YAML without a contract, a contract without a relation, or a relation without a `relation-manager` field stops the start-up.
`cabana.RelationService` serves the panels: linked records, link candidates, link and unlink, under `.../{id}/relations/{name}`. Link and unlink run in a transaction. `pact.RelationExtendManageQuery` scopes the candidates, and `pact.RelationBeforeLink` can check or fill pivot columns before a link is written.
## Relations in lists
A list column can show a related value with `relation` and `select` in `columns.yaml`; see [Lists and filters](lists-and-filters.md). A controller that maps a relation column to a physical column itself implements `pact.ListRelationColumnMapper`.

85
docs/backend/settings.md Normal file
View File

@@ -0,0 +1,85 @@
---
title: Settings
description: Declare singleton settings pages with pact.SettingsItem, backed by a model, a fields.yaml form and validation rules, and read them from plugin code.
section: backend
order: 60
---
# Settings
A WinterCMS settings model extends `SettingModel`, stores its values in `system_settings` and registers its page with `registerSettings`. In SummerCMS a settings page is a singleton row of the plugin's own table, edited through a form described in `fields.yaml`, and declared with `pact.HasSettings`.
## Declaring a settings page
`pact.HasSettings` returns `pact.SettingsItem` entries. Each names the page's code, label, category and icon for the settings index, the permissions it needs, the form file inside the plugin's embedded tree, and a factory for its model:
```go src=modules/cabana/example_controller_test.go#BlogPlugin.Settings
// Settings replaces registerSettings().
func (p *BlogPlugin) Settings() []pact.SettingsItem {
return []pact.SettingsItem{{
Code: "blog",
Label: "acme.blog::lang.settings.label",
Description: "acme.blog::lang.settings.description",
Category: "acme.blog::lang.plugin.name",
Icon: "icon-pencil",
Model: "BlogSettings",
Permissions: []string{"acme.blog.access_settings"},
Form: "models/settings/fields.yaml",
NewModel: func() any { return &BlogSettings{} },
}}
}
```
The model is a GORM struct with a `Fillable` list and a `Rules` method; [cabana](../../modules/cabana/README.md) refuses at start-up a settings model without either:
```go src=modules/cabana/example_controller_test.go#BlogSettings
// BlogSettings is the singleton row behind the plugin's settings page.
type BlogSettings struct {
ID uint `gorm:"column:id;primaryKey"`
PostsPerPage int `gorm:"column:posts_per_page"`
CommentsEnabled bool `gorm:"column:comments_enabled"`
}
```
```go src=modules/cabana/example_controller_test.go#BlogSettings.Rules
// Rules validates a settings save, as a WinterCMS settings model's $rules.
func (BlogSettings) Rules() map[string]string {
return map[string]string{"posts_per_page": "required|integer|between:1,100"}
}
```
The form uses the same field types and options as a controller form; see [Forms](forms.md):
```yaml src=modules/cabana/testdata/docs/models/settings/fields.yaml
fields:
posts_per_page:
label: acme.blog::lang.settings.posts_per_page
type: number
span: left
default: 10
comments_enabled:
label: acme.blog::lang.settings.comments_enabled
type: switch
span: right
```
Create the table in a migration like any other; see [Migrations](../database/migrations.md).
## How values are stored
The settings row is the one with ID 1. Before it exists, the page shows the fields' `default` values and the API reports that the row does not exist yet; the first save creates it. A save writes only fillable fields, validates them with the model's rules and the form's `required` flags, and runs in a transaction, as a controller form save does.
The admin API serves the page at `<prefix>/api/v1/settings/{code}` (values) and `.../settings/{code}/schema` (the form), and `GET .../settings` lists the pages the administrator may open.
## Reading settings in plugin code
Settings are an ordinary table, so plugin code reads them with GORM:
```go src=modules/beachcomber/example_test.go#gate
svc.SetGate(beachcomber.GateFunc(func(ctx context.Context, db *gorm.DB) bool {
var enabled bool
err := db.WithContext(ctx).Raw(`SELECT search_enabled FROM acme_blog_settings WHERE id = 1`).Scan(&enabled).Error
return err == nil && enabled
}))
```
That example reads a flag from a settings row inside a search gate, treating a failed read as off. A setting that changes rarely and that operators, not administrators, own belongs in configuration instead; see [Configuration](../services/configuration.md).

View File

@@ -0,0 +1,83 @@
---
title: Users and permissions
description: Sign administrators in with JWT and cookie auth, declare permissions and navigation, and manage administrators from the console.
section: backend
order: 50
---
# Users and permissions
The admin keeps WinterCMS's backend user model: the `backend_users` and `backend_user_roles` tables, roles with permission grants, and superusers who pass every check. [cabana](../../modules/cabana/README.md) signs administrators in and checks their permissions; the tables are created by the framework migrations that `migrate` runs.
## Signing in
`POST <prefix>/api/v1/auth/login` checks the login and password against `backend_users` and issues a JWT for the admin audience, signed with `admin.jwt.secret`. Login attempts are throttled per `admin.login.max_attempts` and `admin.login.decay_minutes`. `POST .../auth/refresh` reissues a token inside the refresh window, and `POST .../auth/logout` revokes the current token by blacklisting its ID in `backend_jwt_blacklist`.
The admin API accepts the token two ways:
- API clients send `Authorization: Bearer <token>`.
- The admin SPA sends `X-Requested-With: XMLHttpRequest` and receives the token in an HttpOnly, SameSite=Strict cookie. A cookie-authenticated request that changes state must carry that header, which blocks cross-site request forgery.
The guard is registered in [bouncer](../../modules/bouncer/README.md) under the name `backend` and is the middleware of every admin route except login, refresh and the language bundle. See [Authentication](../services/authentication.md) for tokens and guards in general.
The admin keys go in `config/admin.yaml`, with the secret in the environment (`SUMMER_ADMIN__JWT__SECRET`):
```yaml
jwt:
ttl: 60
refresh_ttl: 20160
password:
bcrypt_cost: 12
login:
max_attempts: 5
decay_minutes: 1
```
The cookie carries the Secure attribute. `backend.cookie_secure: false` drops it for plain-HTTP development and is refused in the `production` environment.
## Permissions
A plugin declares its permissions with `pact.HasPermissions`, the Go form of `registerPermissions`, and its menu entries with `pact.HasNavigation`:
```go src=modules/cabana/example_controller_test.go#BlogPlugin.Permissions
// Permissions replaces registerPermissions().
func (p *BlogPlugin) Permissions() []pact.Permission {
return []pact.Permission{
{Code: "acme.blog.access_posts", Tab: "acme.blog::lang.plugin.name", Label: "acme.blog::lang.permissions.posts"},
{Code: "acme.blog.access_settings", Tab: "acme.blog::lang.plugin.name", Label: "acme.blog::lang.permissions.settings"},
}
}
```
```go src=modules/cabana/example_controller_test.go#BlogPlugin.Navigation
// Navigation replaces registerNavigation().
func (p *BlogPlugin) Navigation() []pact.NavigationItem {
return []pact.NavigationItem{{
Code: "blog",
Label: "acme.blog::lang.plugin.name",
Icon: "icon-pencil",
Permissions: []string{"acme.blog.access_posts"},
Controller: "acme.blog.posts",
SideMenu: []pact.NavigationItem{
{Code: "posts", Label: "acme.blog::lang.posts.title", Controller: "acme.blog.posts"},
},
}}
}
```
A controller's `pact.AdminPermissioned.RequiredPermissions` are checked before any schema is served or query runs, and navigation and settings entries are filtered by the permissions they name, so an administrator sees only what they may open. `cabana.Allows` is the check: superusers pass, a grant ending in `.*` matches every code with that prefix, and an empty requirement list allows any signed-in administrator. The last lines of the activation example on [Admin controllers](admin-controllers.md) show it.
Actions registered through `pact.HasAdminActions` may name extra permissions, checked on top of the controller's.
## Managing administrators
The application binary has two commands for operators:
```sh
./bin/acme admin:create --email admin@example.com --password '<secret>' --superuser
./bin/acme admin:reset-password admin@example.com --password '<secret>'
```
`admin:create` creates an activated administrator; `--login` defaults to the lower-cased email and `--role <code>` assigns a role. `admin:reset-password` takes a login or an email, sets the password and revokes every token issued before the reset. Passwords are hashed with bcrypt at `admin.password.bcrypt_cost`, so hashes copied from a WinterCMS database keep working.
> [!TIP]
> Pass the password through an environment variable or a prompt of your shell rather than typing it on the command line, where it stays in the shell history.