Files
summercms/modules/cabana/README.md
Jakub Zych 8b1cb244de 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
2026-09-28 23:41:17 +02:00

16 KiB

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 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 mounts under the admin prefix (backend.uri, default /backend). The JSON API lives under <prefix>/api/v1, and every other path under the prefix serves the admin SPA from boardwalk.

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, 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, 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.
  • 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 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 <prefix>/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 .../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.

Controller assets

GET <prefix>/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 <prefix>/assets/acme/blog/js/lookup.js, and the schemas list it as <prefix>/assets/acme/blog/js/lookup.js?v=<first 12 hex characters of its sha256>. 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 <prefix>/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:

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 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.

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 <code>, --superuser Creates an activated backend administrator.
admin:reset-password <identifier> (login or email), --password Sets a new password and revokes every token issued before the reset.
./bin/acme admin:create --email admin@example.com --password '<secret>' --superuser
./bin/acme admin:reset-password admin@example.com --password '<secret>'

Dependencies

  • SummerCMS modules: backpack, boardwalk, bonfire, bouncer, lagoon, pact, party, phrasebook, towel.
  • Third-party: gorm.io/gorm (with gorm.io/gorm/clause), github.com/goccy/go-yaml (with its ast package).
  • Standard library: bytes, context, crypto/sha256, database/sql, encoding/hex, encoding/json, errors, fmt, 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

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/.