# cabana Schema-driven admin backend that compiles WinterCMS-style YAML list, form, filter and relation definitions at boot and serves them as a JSON admin API next to the embedded admin SPA. `import "git.golem15.com/golem15/summercms/modules/cabana"` ## Overview `cabana` is the SummerCMS counterpart of WinterCMS's backend: controllers with the List, Form and Relation behaviors, their `config_list.yaml`, `config_form.yaml`, `config_filter.yaml` and `config_relation.yaml` files, the model `columns.yaml` and `fields.yaml`, backend users, roles and permissions, settings models and backend navigation. Plugins declare admin controllers through the [pact](../pact/README.md) capability interfaces and embed their YAML; `cabana.Activate` compiles all of it once at boot, fails fast on any schema error, and returns the admin routes that [surf](../surf/README.md) mounts under the admin prefix (`backend.uri`, default `/backend`). The JSON API lives under `/api/v1`, and every other path under the prefix serves the admin SPA from [boardwalk](../boardwalk/README.md). ## Features - Boot-time schema compilation: `cabana.CompileList` and `cabana.CompileForm` read a controller's YAML from the plugin's embedded tree, check that `modelClass` matches the controller's model name, and cache a locale-neutral schema. Each request gets a translated copy (`cabana.ListSchema.Localize`, `cabana.FormSchema.Localize`, `cabana.RelationSchema.Localize`) through [phrasebook](../phrasebook/README.md), with CLDR plural forms for the SPA's messages. - 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), 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 and for linking and unlinking. Framework code never guesses table, pivot or foreign-key names: the controller supplies them. - 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 with scalar values. 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. - 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. - Server-rendered partials: `headerPartial: ` in `config_list.yaml` (a strip above the list) and `type: partial` with `path: ` in `fields.yaml` render the template `{ConfigDir}/_.htm` with `html/template` against a view model from the controller's `pact.AdminPartialData`. The result reaches the SPA as an allowlisted node tree, never as an HTML string. A missing or unparsable template, a free-form path or a controller without `pact.AdminPartialData` fails boot. - Singleton settings screens declared with `pact.HasSettings`, read and saved by `cabana.SettingsService`. - Backend navigation (`pact.HasNavigation`) and permissions (`pact.HasPermissions`), filtered per user by `cabana.Registry.Metadata`. `cabana.Allows` implements the permission check: superusers pass, and grants ending in `.*` match by prefix. - Admin authentication against WinterCMS's `backend_users` and `backend_user_roles` tables (`cabana.BackendUser`, `cabana.BackendUserRole`, `cabana.BackendUsers`): a JWT guard registered in [bouncer](../bouncer/README.md) as `backend`, login throttling, token refresh and revocation, and two transports. API clients use a Bearer token; the SPA sends `X-Requested-With: XMLHttpRequest` and receives the token in the HttpOnly, SameSite=Strict cookie named by `cabana.AdminCookieName`. Cookie-authenticated requests that change state must carry that header, which blocks cross-site request forgery. - A consistent JSON envelope for every response: `cabana.WriteData`, `cabana.WriteError` and `cabana.WriteErrorDetails`, typed for documentation as `cabana.Envelope`, `cabana.ListEnvelope`, `cabana.RecordEnvelope` and `cabana.ErrorEnvelope`. - OpenAPI documentation: `cabana.AdminList`, `cabana.AdminCreate` and the other `Admin*` functions have empty bodies and exist only to carry the swag annotations of each admin route. - Operator commands for creating administrators and resetting their passwords (see CLI commands). ### Admin API routes All paths are relative to `/api/v1`. A controller ID `vendor.plugin.controller` maps to the path `/{vendor}/{plugin}/{controller}`. | Method and path | Purpose | |-----------------|---------| | POST `/auth/login`, POST `/auth/refresh` | Sign in (throttled) and refresh a token. Public. | | GET `/lang` | The `backend::lang` string bundle for the request locale. Public, so the login screen can load it. | | POST `/auth/logout`, GET `/auth/me` | Revoke the current token; return the signed-in administrator. | | GET `/navigation`, GET `/settings` | Navigation and settings entries the administrator may open. | | GET `/settings/{code}/schema`, GET and PUT `/settings/{code}` | Settings form schema, values and update. | | GET `/{vendor}/{plugin}/{controller}/schema/list`, `.../schema/form`, `.../schema/relation/{name}` | Localized list, form and relation schemas. | | GET and POST `/{vendor}/{plugin}/{controller}` | List records; create a record. | | GET, PUT and DELETE `/{vendor}/{plugin}/{controller}/{id}` | Show, update and delete a record. | | POST `/{vendor}/{plugin}/{controller}/bulk-delete` | Delete a set of records in one transaction. | | 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}/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 `.../fields/{field}/options`, GET `.../filters/{scope}/options` | Choices for a relation field and for a model-backed list filter. | | GET `.../{id}/relations/{name}`, GET `.../{id}/relations/{name}/candidates` | Linked records and link candidates of a relation manager. | | POST `.../{id}/relations/{name}/link`, POST `.../{id}/relations/{name}/unlink` | Link and unlink related records. | Every path under the prefix that no API route matches is served by the admin SPA; unmatched API paths return the `not_found` error envelope instead. ### Partials A partial is an `html/template` file next to the controller's YAML: `headerPartial: stats` and `path: stats` both resolve to `{ConfigDir}/_stats.htm`; Winter's `$/` and `~/` paths are not supported. The template's root is `.Data`, the value the controller's `PartialData(ctx, name, record)` returns, and `trans ""` translates a phrase key in the request locale. `record` is nil for a header partial and for a form partial on the create form; with `?id=` it is the record cabana loaded through the controller's `pact.FormExtendQuery` scope, so a plugin never looks a record up by a request id itself. The view model must be a curated struct built for the template. cabana refuses a value of the controller's own model type (or a pointer to or a collection of it) and a type that carries `html/template`'s pre-escaped content types, so escaping stays on for every record value. The rendered output is parsed with `golang.org/x/net/html` and walked through an allowlist: - Elements: `div span p strong em b i u s small mark code pre br hr ul ol li dl dt dd h2 h3 h4 h5 h6 table thead tbody tfoot tr th td caption section header footer figure figcaption blockquote q abbr time data meter progress sup sub a img`. Any other element is unwrapped (its children stay); `script style template iframe object embed noscript textarea title xmp svg math form input button select link meta base` are removed with everything inside them, and comments disappear. - Attributes: `class title lang dir role`, `aria-*` and `data-*` everywhere; `a[href]` and `img[src]` only for a same-origin path starting with exactly one `/` (links may also use `#fragment`); `img[alt width height]`, `td`/`th[colspan rowspan scope]`, `time[datetime]`, `data[value]`, `meter[value min max low high optimum]`, `progress[value max]`. `id`, `style` and every event handler are dropped. - Caps: 64 KiB of template output, 2000 nodes and a depth of 32. Exceeding one, a view model error or a refused view model is logged with the controller and partial name and answered with the generic 500 body, never a truncated tree. A statistics strip above a list, for example: ```html
{{ trans "acme.blog::lang.stats.posts" }}
{{ .Data.Total }}
``` ### Partial style kit and plugin CSS variables The admin SPA ships a small set of stable CSS classes that partial templates may use through the allowlisted `class` attribute, so server-rendered content looks native without any plugin CSS: | Class | Use | |-------|-----| | `summer-partial` | Set by the SPA on every partial's root: 14px/1.5 body text, long words and URLs wrap, `p`/`ul`/`ol` spaced 8px apart, links underlined with the focus ring. | | `summer-stats` | A card strip (surface background, border, 16px radius, card shadow, 16px 20px padding) whose items wrap onto more rows with a 32px column gap and an 8px row gap. Safe on a `
`. | | `summer-stat` | One item of the strip: the value is shown above the label while `
` stays first in the DOM. | | `summer-stat__label` | The item label: 13px, muted, wraps. | | `summer-stat__value` | The item value: 20px, weight 600, tabular numbers. | Use `
` with one `
` per item holding a `
` and a `
`, as in the example above. Plugin CSS (declared through `pact.AdminClientAssets`) and any widget shadow DOM may read only these public variables. They inherit into shadow roots and switch automatically in dark mode: `--c-bg`, `--c-surface`, `--c-subtle`, `--c-border`, `--c-border-strong`, `--c-text`, `--c-muted`, `--c-placeholder`, `--c-primary`, `--c-on-primary`, `--c-danger`, `--c-danger-soft`, `--c-hover`, `--c-sel`, `--c-skel`, `--c-ring`. Plugins must not hardcode hex colours and must not rely on Tailwind utility classes: the SPA build purges every utility it does not use itself. A controller's stylesheets are disabled while another controller's list or form is open. ### Controller assets `GET /assets/{vendor}/{plugin}/{file...}` serves the files controllers declare through `pact.AdminClientAssets`. A plugin file `assets/js/lookup.js` of plugin `acme.blog` is served at `/assets/acme/blog/js/lookup.js`, and the schemas list it as `/assets/acme/blog/js/lookup.js?v=`. The route is public, like the SPA shell, and serves only the exact files declared at boot, never the plugin's embedded tree: YAML and templates are not reachable, and any other path falls through to the SPA, which also serves its own build assets under `/assets/`. Each response carries an explicit JavaScript or CSS `Content-Type`, `X-Content-Type-Options: nosniff`, the admin Content-Security-Policy (`script-src 'self'`), `Cross-Origin-Resource-Policy: same-origin`, `Cache-Control: no-cache` and a sha256 `ETag`, so conditional requests answer 304 and a rebuilt binary is picked up at once. ## Usage A plugin exposes an admin controller and embeds its YAML. `summer make:admin-controller` scaffolds the controller type and its four YAML files: ```go package blog import ( "embed" "io/fs" "git.golem15.com/golem15/summercms/modules/pact" ) // adminFS holds controllers/post/config_list.yaml, controllers/post/config_form.yaml, // models/post/columns.yaml and models/post/fields.yaml. // //go:embed controllers models var adminFS embed.FS type Post struct { ID uint `gorm:"primaryKey"` Title string `gorm:"column:title"` } func (Post) TableName() string { return "acme_blog_posts" } type postAdmin struct{} func (postAdmin) ID() string { return "acme.blog.post" } func (postAdmin) ModelName() string { return "Post" } // must equal modelClass in the YAML func (postAdmin) ConfigDir() string { return "controllers/post" } func (postAdmin) NewRecord() any { return &Post{} } // pact.AdminRecordSource // Plugin also implements party.Plugin (ID, Requires, Register, Boot). type Plugin struct{} func (p *Plugin) AdminControllers() []pact.AdminController { return []pact.AdminController{postAdmin{}} } func (p *Plugin) AdminFS() fs.FS { return adminFS } ``` `config_list.yaml` and `config_form.yaml` reference the model files with WinterCMS paths such as `~/plugins/acme/blog/models/post/columns.yaml`. At boot, `surf.BuildRouter` calls `cabana.Activate` with the activated plugins and mounts the returned `cabana.Routes`; when no plugin registers an admin controller, `cabana.Activate` returns nil and no admin routes exist. Record and user lookups use the `*gorm.DB` that [lagoon](../lagoon/README.md) publishes on the `backpack.App`. ## API reference | Identifier | Description | |------------|-------------| | `cabana.Activate` | Compiles every plugin's admin controllers, settings, navigation and permissions and returns the admin `cabana.Routes`, or nil when there are no controllers. | | `cabana.Routes` | Guard middleware, mount function and normalized prefix of the admin API and SPA. | | `cabana.AdminPrefix` | Reads and validates `backend.uri`; `cabana.DefaultAdminPrefix` is the fallback. | | `cabana.RuntimeCommands` | Returns the `admin:create` and `admin:reset-password` commands. | | `cabana.CompileList` / `cabana.CompileForm` | Compile a controller's list and form YAML into cached schemas. | | `cabana.ListSchema` / `cabana.FormSchema` / `cabana.RelationSchema` | Locale-neutral compiled schemas; each request works on a localized copy. | | `cabana.CompiledController` | One controller after compilation: list, form, relations and writable fields. | | `cabana.Registry` | Immutable map of compiled controllers and settings, with permission-filtered metadata. | | `cabana.CRUDService` | Schema-projected show, create, update, delete, bulk delete and relation options. | | `cabana.ExecuteList` | Runs an allowlisted, paginated list query for a controller. | | `cabana.RelationService` | Linked, candidate, link and unlink operations of relation managers. | | `cabana.SettingsService` | Reads and transactionally updates singleton settings rows. | | `cabana.FieldRelationProvider` / `cabana.FieldRelationContract` | Controller-supplied bindings for `type: relation` form fields. | | `cabana.AdminRelationContractProvider` / `cabana.RelationContract` | Controller-supplied bindings for relation managers. | | `cabana.BackendUser` / `cabana.BackendUserRole` / `cabana.BackendUsers` | GORM models of the backend user tables and the principal loader used by the guard. | | `cabana.Allows` | Checks a principal against required permission codes. | | `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.AdminActionRequest` | Body of an action route: optional `record_id` and the widget's `values`. Unknown keys are refused. | | `cabana.AdminActionResult` | Answer of an action route: the localized `message` and the filtered `fill` object. | | `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.PartialView` / `cabana.PartialNode` | A rendered partial: a list of nodes, each an allowlisted element (`tag`, `attrs`, `children`) or a text node (`text`). | ## Configuration | Key | Default | Effect | |-----|---------|--------| | `admin.jwt.secret` | none | HMAC secret for admin tokens. Required as soon as any plugin registers an admin controller; boot fails without it. Set it through `SUMMER_ADMIN__JWT__SECRET` rather than a committed file. | | `admin.jwt.ttl` | `60` | Access token lifetime in minutes. | | `admin.jwt.refresh_ttl` | `20160` | Refresh window in minutes (14 days); also the lifetime of the admin cookie. | | `admin.jwt.blacklist_grace` | `0` | Seconds a token stays valid after it has been refreshed, for requests already in flight. | | `admin.password.bcrypt_cost` | `10` | Bcrypt cost for administrator passwords; values outside 4 to 31 fall back to 10. | | `admin.login.max_attempts` | `5` | Login attempts allowed per throttle window. | | `admin.login.decay_minutes` | `1` | Length of the login throttle window in minutes. | | `backend.uri` | `/backend` | Admin mount path. One or more lowercase path segments; boot fails on an invalid value. | | `backend.cookie_secure` | `true` | Set `false` to drop the cookie's Secure attribute for plain-HTTP development. Refused in the `production` environment. | | `app.url` | empty | Base URL used for the token issuer. | The backend user, role and token blacklist tables (`backend_users`, `backend_user_roles`, `backend_jwt_blacklist`) are created by `lagoon.BackendAdminMigrations`, which the `migrate` command runs. ## CLI commands Both commands are added to every application binary by the generated `main` and open the database themselves when the application has not. | Command | Arguments and flags | Effect | |---------|---------------------|--------| | `admin:create` | `--email`, `--password` (both required), `--login` (defaults to the lower-cased email), `--role `, `--superuser` | Creates an activated backend administrator. | | `admin:reset-password` | `` (login or email), `--password` | Sets a new password and revokes every token issued before the reset. | ```sh ./bin/acme admin:create --email admin@example.com --password '' --superuser ./bin/acme admin:reset-password admin@example.com --password '' ``` ## Dependencies - SummerCMS modules: [backpack](../backpack/README.md), [boardwalk](../boardwalk/README.md), [bonfire](../bonfire/README.md), [bouncer](../bouncer/README.md), [lagoon](../lagoon/README.md), [pact](../pact/README.md), [party](../party/README.md), [phrasebook](../phrasebook/README.md), [towel](../towel/README.md). - Third-party: `gorm.io/gorm` (with `gorm.io/gorm/clause`), `github.com/goccy/go-yaml` (with its `ast` package), `golang.org/x/net/html` (with its `atom` package; parses rendered partials for the allowlist walk). - Standard library: `bytes`, `context`, `crypto/sha256`, `database/sql`, `encoding/hex`, `encoding/json`, `errors`, `fmt`, `html/template`, `io`, `io/fs`, `log/slog`, `math`, `net`, `net/http`, `path`, `reflect`, `regexp`, `sort`, `strconv`, `strings`, `time`. - Tests additionally use `github.com/testcontainers/testcontainers-go` and its `modules/postgres` package. ## Testing ```sh go test ./modules/cabana/... ``` The database-backed tests start a PostgreSQL container through testcontainers-go and need Docker. Run `go test -short ./modules/cabana/...` to skip them. YAML fixtures for the schema compiler live in `testdata/`.