# 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. - 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. | | 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. ## 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. | ## 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). - Standard library: `bytes`, `context`, `database/sql`, `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 ```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/`.