Files
summercms/modules/cabana
Jakub Zych 23f33c3911 feat(17-01): tree reorder page and singleton admin controllers
- cabana: config_reorder.yaml (title, nameFrom, modelClass, parentColumn,
  sortColumn, maxDepth, partition) with fail-loud boot checks and the
  built-in reorder toolbar button; list schema carries reorder
- GET/POST {controller}/reorder: scoped set (ListExtendQuery plus the
  partition) locked FOR UPDATE, 409 reorder_stale on any set difference,
  422 for duplicates, foreign parents, cycles and maxDepth, both columns
  written without model hooks
- pact.AdminSingleton: GET {controller}/singleton (first or create, form
  scope checked), form schema singleton, list/create/bulk/delete 404,
  no config_list.yaml needed
- swag, inventories, token classes, CSRF walk, conformance cases, backend
  lang reorder.stale and load_failed, reorder and singleton smoke tests, docs
2026-10-12 15:35:43 +02:00
..

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"

Name: The beach hut where the staff run the beach; it is the admin backend.

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.

Role in the framework

The largest module and the admin backend of the framework. It sits on top of backpack, lagoon, bouncer, party, pact, phrasebook, towel and bonfire, and it mounts boardwalk to serve the admin SPA. surf wires its routes in. It mirrors the WinterCMS backend: YAML-driven lists, forms, filters and relations compiled once at boot.

Use cases:

  • Give a model such as a blog post a full admin screen from fields.yaml and columns.yaml, with no Vue code.
  • Add relation managers, filters, bulk actions and settings pages declaratively.
  • Add custom form widgets and controller actions that run behind the admin's CSRF and permission checks.

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; only a type: repeater field's list of rows is exempt from the nested-value drop. Declared mltext, mltextarea and mlmarkdown locale maps are lifted before that drop: the default locale becomes the host scalar and the other locales are written through a published cabana.TranslationWriter inside the same permissioned save transaction. Values are filled and validated through lagoon; a value that does not fit its column (a lagoon.FillTypeError, such as a fraction for an integer field) is a 422 validation_failed on that field, 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, linking and unlinking, and creating related records. A contract is a belongsToMany (cabana.RelationBelongsToMany, the kind of a contract that leaves Kind empty) with a pivot model, or a hasMany (cabana.RelationHasMany) whose ForeignKey column on the related model points at the parent. A relation's child form comes from manage.form in config_relation.yaml (or a top-level form, WinterCMS's fallback), its read-only preview from view.form (same fallback), and a belongsToMany's pivot columns are edited through pivot.form (WinterCMS pivot[x] field names compile to x; pivot keys, timestamps and hook columns are refused); WinterCMS $/<vendor>/<plugin>/... paths resolve inside the same plugin only. A relation form accepts the scalar field types plus datepicker and fileupload; relation, relation-manager, widget and partial fail boot. The view panel's toolbarButtons (create, update, delete, link, unlink) are the capability of their routes. A relation's messages block takes the link keys (link, linkHint, candidateSearch, linked, unlinkSelected, unlinkConfirm, unlinked, empty) and the child and pivot modal keys (create, createTitle, updateTitle, previewTitle, created, updated, deleteSelected, deleteConfirm, deleteOneConfirm, deleted, pivotTitle, pivotSaved, editPivot, createSubmit, updateSubmit, pivotSubmit, linkSubmit), each defaulting to backend::lang.messages.relation.*. Framework code never guesses table, pivot or foreign-key names: the controller supplies them. A belongsTo field over a protected foreign key is read-only unless its contract sets WritableForeignKey; the protected key list itself does not change, and the submitted id is still rechecked through the scoped options query. A controller implementing cabana.RelationLockProvider returns a cabana.RelationLock per field and request: options and labels carry locked: true for those ids, and a create or update that adds or removes a locked id is answered 403 forbidden before any row is written.
  • 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 whose values encode as JSON scalars (a value whose MarshalJSON writes an array or object, NaN or an infinity is dropped). Like the list schema's toolbarActions, the form schema carries a widget field only when the requesting administrator may run its action. The field's context applies to the action route as it does on save: a request without record_id is the create form's and one with it the update form's, and a widget its context hides on that form answers 404. Besides record_id and values, the request may carry payload, the widget's own JSON value (at most 64 KiB, a 422 above that; refused on toolbar and record routes), which reaches the action unfiltered as the Payload field of pact.AdminActionInput; the action may answer with Data on pact.AdminActionResult, written to the response as data exactly as it encodes (any JSON up to 256 KiB encoded, a 500 above that or when it cannot be encoded), not subject to the fill allowlist and omitted when nil. The element asks for its action with a summer-action event whose optional detail.payload becomes the request payload, and receives the answer as a data attribute plus a summer-result event with {data, fill, message}. 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.
  • Bulk actions: bulkActions in config_list.yaml lists names the controller registers through pact.HasAdminBulkActions; it needs showCheckboxes: true. Bulk actions have their own namespace (create and delete are reserved there too), and each needs a label. The posted ids are resolved and row-locked through pact.ListExtendQuery in one transaction and the action receives the loaded records, never ids: a selection that matches nothing answers affected: 0 without running the action, and a partial match answers 409 and rolls back. The list schema's bulkActions carries the built-in delete and only the declared actions the requesting administrator may run, with localized label and confirm; an unknown or duplicate name fails boot. Each run is logged with the controller, action, administrator and affected count.
  • Invisible columns and filter choices: invisible: true in columns.yaml keeps a column searchable and sortable on the server while the schema flags it, the SPA does not render it and list rows do not carry it. The choices of a scope filter are served by the controller when it implements pact.FilterOptions, else by the model.
  • Row state: a controller implementing pact.ListRowStates is called once per list page with the page's records and the list's database handle. The list response carries meta.row_states, keyed by row id, with values from the fixed set deleted, negative, disabled in that order; a value outside the set is dropped and logged, rows without a state are left out, and a controller without the hook sends no row_states key. The badge texts are the list messages rowStateDeleted, rowStateNegative and rowStateDisabled, defaulting to backend::lang.messages.list.row_state_*. A soft-deleted record that the controller's pact.ListExtendQuery and pact.FormExtendQuery include can be shown, updated (it stays soft-deleted), targeted by bulk and record actions and removed for good by the controller's pact.FormAfterDelete.
  • Record actions: recordActions in config_form.yaml lists names the controller registers through pact.HasAdminRecordActions, a third action namespace with the same reserved names. It needs the form's preview block: record actions are offered on the preview screen, and a form that declares them without one fails boot. The show response's meta.actions (cabana.RecordAction entries with localized label and confirm) carries only the declared actions the requesting administrator may run and whose Applies reports true for the record; the key is absent when none is offered, and create and update responses never carry it. The action route loads the record through pact.FormExtendQuery with a row lock in one transaction (one 404 for a missing and an out-of-scope id), checks Applies again (409 when it reports false) and then runs the action. An unknown or duplicate name, or an action without a label, fails boot. Each run is logged with the controller, action, administrator and record id.
  • Preview screen: a preview mapping in config_form.yaml (preview: {}, or with headerPartial: <name> for a status hint) gives the form a read-only record screen in the admin SPA. The form schema reports it as preview (cabana.FormPreview), fields with context: preview are shown only there and are never written by a save, messages.preview and messages.edit name the screen's subtitle and edit button, and recordUrl and the form redirects may point at it as .../preview/:id. An empty preview: key or an unknown key inside it fails boot.
  • Plugin field types: a plugin implementing pact.HasFormFieldTypes registers admin form field types under short aliases that any controller's fields.yaml uses as type:. Boot checks the aliases (no built-in type, no alias claimed by two plugins), the element prefix, the scripts and styles and each field's option keys and CheckOptions. A save runs the type's Decode inside the save transaction after the record is loaded: the decoded value is stored, a pact.FormFieldError is a 422 on the field, any other error an opaque 500, and the notices a type returns are localized into the response's meta.notices (cabana.FieldNotice), which is absent when no type reported anything. Show and save responses serve the value through the type's Load. A translatable type's value is a locale map stored through the same lift, cabana.TranslationWriter and hydrate path as mlmarkdown, decoded and loaded per locale. On update, a value equal to the one the form was served for that field and locale is neither decoded nor written. Form hooks read what a save wrote through cabana.FieldWritesFromContext. A list column may name a plugin alias as its type and shows the type's ListValue. The cabana-owned upload route hands an element's file to the type's Upload. The form schema carries element, translatable and typeOptions for such a field. Settings and relation forms refuse plugin field types (supported in controller forms only). See Plugin field types.
  • Form-only fields: a controller implementing pact.FormVirtualFields lists fields of its fields.yaml that are not columns of the form. They are exempt from the column binding, never filled into the model and never part of a record response; the values an administrator submits reach the Form hooks through cabana.VirtualFieldsFromContext, only for fields whose context allows the operation, and a nested value is a 422 on the field. type: password is a masked field that must be listed this way, so a password is sent in a save body and never comes back. A controller implementing pact.FormRules supplies the validation rules per operation (create or update), which replace the model's Rules() for admin saves; a rule on a virtual field is checked against the submitted value, never against a model column of the same name. Neither is available on settings forms or relation forms.
  • Permission editor: a type: permissioneditor field with mode: radio (allow 1, inherit, deny -1) or mode: checkbox (allow 1) edits a record's permission set as a JSON object of code to integer. The controller implements cabana.PermissionEditorProvider: it returns the offered cabana.PermissionOption list per request (served on the field as permissionOptions, with locked for permissions the administrator may not change) and reads and stores the record's values, so the storage shape is the plugin's. A save answers 422 on the field for a value that is not an object of integers, a code that is not offered or a value outside the mode's set, and 403 forbidden when a locked code's value changes; stored codes that are not offered are kept. The widget fill contract is unchanged: a widget still writes scalar fields only.
  • Markdown and multilingual fields: type: markdown edits source on a host text column; cabana.RenderMarkdown turns that source into HTML with the pinned goldmark engine and no unsafe HTML, and leftover script or iframe tags, event handlers, or javascript, vbscript or data URLs are rejected; the admin form preview renders through it via POST /markdown/preview. type: mltext, type: mltextarea and type: mlmarkdown take a JSON object of locale code to string; mltextarea is the multi-line variant and takes size like textarea. The form schema's cabana.FormMeta.EnabledLocales lists every enabled code when a cabana.TranslationWriter is published, default locale first and then the other enabled codes in the writer's order, so every ML control opens on the locale stored in the host columns; create seeds an empty string per code, and Show/save replace the host scalar with that map via hydrateMLRecord and TranslationWriter.TranslatedExact (missing non-default codes stay empty; D-11 fallback is not applied). A GET host string is merged onto the seed so sibling locales are not dropped. The default locale fills the host column; other locales reach a plugin-published cabana.TranslationWriter after the host row has a primary key, still inside the controller's permissioned save transaction. Relation-child create, update and show on cabana.RelationService use the same lift, apply and hydrateMLRecord path as controller save, still with no standalone translate-write route. An unknown field type, including an unknown ml* type, fails boot.
  • Rich text: type: richeditor edits HTML in a host text column (the admin SPA shows a WYSIWYG editor). It takes the common field keys plus size; any other key fails boot, and settings and relation forms refuse the type (supported in controller forms only). On create and update a submitted value must be a string or null (anything else is a 422 on the field) and is sanitized through seaglass before it is filled: HTML the policy keeps whole is stored byte for byte, otherwise the clean HTML is stored and what was removed is reported in meta.notices under the field (backend::lang.richeditor.removed_elements, removed_attributes, removed_comments with the count and names, or markup_changed).
  • Repeater: type: repeater edits a JSON list in one column (lagoon.Jsonable) with a nested form.fields of text, textarea, number, checkbox, switch, dropdown (static, method or dependsOn a field of the same row) and richeditor fields, plus prompt, minItems, maxItems and titleFrom (cabana.RepeaterForm in the schema; the span is always full). Other nested types, a nested repeater, nested tab, context or preset, and WinterCMS's groups and style fail boot; settings and relation forms refuse the type. A save decodes the rows before the projection: null is an empty list, anything but a list of objects is a 422 on the field, each row keeps only the nested names with scalars coerced per type, a required nested value missing in a row is a 422 under field.index.nested, the row bounds are 422s on the field (repeater.max_items, repeater.min_items), nested richeditor values are sanitized with notices under the row path, and nested dependent dropdowns are rechecked. Record responses carry the list of rows.
  • Tree reorder (D-09): a config_reorder.yaml (title, nameFrom, modelClass, parentColumn default parent_id, sortColumn default sort_order, maxDepth, and an optional partition with column, relation, nameFrom and label) gives the controller a reorder page and the built-in reorder toolbar button; unknown keys, columns that are not record columns, a partition relation that is not a belongsTo relation field over the column, and a reorder button without the file fail boot. The list schema carries reorder (cabana.ListReorder). GET .../reorder answers the scoped tree (cabana.AdminReorderTree); POST .../reorder locks the scoped set (ListExtendQuery plus the partition) in one transaction, answers 409 reorder_stale when the posted ids differ from it in any way, 422 for a duplicate, a foreign parent, a cycle or a depth above maxDepth, and otherwise writes both tree columns without model hooks.
  • Singleton controllers (D-11): a controller implementing pact.AdminSingleton needs only config_form.yaml; GET .../singleton answers the id of the record SingletonRecord returns (created when absent) after checking the form scope, the form schema carries singleton, and the list, create, bulk and delete routes answer 404. A settings link entry may open it.
  • Dependent dropdowns: dependsOn: <field> on a type: dropdown field names a sibling with a scalar value (the same form, or the same repeater row); the dropdown takes no options and its controller implements pact.DependentOptionsProvider, else boot fails. GET .../fields/{field}/dependent-options?values[<sibling>]=<value> answers cabana.AdminDependentOptionsResult (applies and the localized options with their depth); {field} is repeater.field for a dropdown in a repeater row; the form schema carries dependsOn. Every save rechecks the value with the submitted sibling value (per row in a repeater): a value that does not apply is stored null, one outside the options is a 422 form.dependent_invalid on the field. A menu or sitemap controller returns signpost.ReferenceOptions here, the one admin binding of the signpost menu item type registry. Settings and relation forms refuse dependsOn (supported in controller forms only).
  • Preset fields: preset on a type: text or type: mltext field (a source field name, or a mapping with field and type, slug or exact) makes the field follow another text or mltext field of the same form on the create screen until the administrator edits it, per locale for an mltext pair. The schema reports it as preset (cabana.FieldPreset); the server does not fill the field.
  • Server-rendered partials: headerPartial: <name> in config_list.yaml (a strip above the list) and type: partial with path: <name> in fields.yaml render the template {ConfigDir}/_<name>.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.
  • Date pickers: a type: datepicker field in fields.yaml edits a date (mode: date, a lagoon.Date column), a date and time (mode: datetime, the default, a time.Time column stored in UTC) or a time of day (mode: time, a lagoon.TimeOfDay column); pointers to the three types make the value optional. It accepts WinterCMS's mode, format (a PHP date() format, served also as displayFormat in the SPA's tokens), minDate, maxDate, yearRange, firstDay, twelveHour and ignoreTimezone; any other key, a format letter with no equivalent, bounds on mode: time, ignoreTimezone outside mode: datetime or a column whose Go type does not match the mode fails boot. The save rechecks minDate and maxDate on the calendar date and answers 422 on the field. List columns take type: date and type: time for these columns; when type is omitted, a time.Time column is compiled as datetime, a lagoon.Date column as date and a lagoon.TimeOfDay column as time. A struct column that implements sql.Scanner or driver.Valuer is never taken for a relation.
  • File uploads: a type: fileupload field in fields.yaml edits an attachOne or attachMany relation the record model declares through attach.HasRelations (its AttachRelations method) next to attach.Owner. The field accepts WinterCMS's mode (image or file), fileTypes, mimeTypes, maxFilesize (megabytes), maxFiles (attachMany only), imageWidth, imageHeight, thumbOptions (only mode: auto, exact, crop or fit), useCaption and prompt; any other key, an image-mode file type outside jpg, jpeg, png, gif and webp, a name that is not a declared relation or a maxFilesize whose file plus 64 KiB of multipart framing exceeds http.body_limits.upload_bytes fails boot. Uploads and removals are deferred, as in WinterCMS: the SPA sends a random form session key in the X-Session-Key header (cabana.SessionKeyHeader) with every file call and with the save, the server keeps the pending work in deferred_bindings against that key and the signed-in administrator, and the record's next create or update save applies it inside its transaction. A retry of the same upload may send X-Upload-Id so the server returns the already stored file. A save that fails with 422 keeps the pending uploads; another administrator's key matches nothing. The upload route caps the request body at the smaller of http.body_limits.upload_bytes and maxFilesize plus 64 KiB and answers 413 payload_too_large past it; the size, type and image checks run on the server (through attach.Store) and answer 422 on the field. A file list (cabana.FileItem) carries url and thumb_url only for a public relation.
  • Refusals: a lifecycle hook, a relation hook or a bulk, record, toolbar or widget action that returns a cabana.ForbiddenError is answered 403 forbidden with the error's Message and Details (a field name to a list of messages), both translated in the request locale; an empty Message stays empty. The surrounding transaction is rolled back. Every other error that is not a cabana.ValidationError stays the opaque 500, logged on the server.
  • Singleton settings screens declared with pact.HasSettings, read and saved by cabana.SettingsService. A settings entry whose Controller field is set links to that admin controller instead: start-up fails when it also declares a model, form or model factory, or names an unknown controller; GET /settings lists it, with its controller, only to administrators who pass its permissions and may open the controller; and the singleton settings endpoints answer 404 for its code.
  • Backend navigation (pact.HasNavigation) and permissions (pact.HasPermissions), filtered per user by cabana.Registry.Metadata. An administrator's own backend_users.permissions are merged over the role's as in Winter (a -1 denies a code the role grants). cabana.Allows implements the permission check with Winter's hasAnyAccess semantics for JWT sessions: superusers pass, a principal needs any one of the listed codes, and wildcards match on both sides (a grant ending in .* covers every code with that prefix, and a required code ending in .* is met by any grant under it). When the principal carries a non-full bouncer.ScopeLimit (a personal API token), the scope is checked first: one method-allowed scope must match every required code and the owner must hold a required code inside that scope; a superuser with a scoped token is limited to the scope, and an empty requirement list is refused unless the scope is full.
  • Admin authentication against WinterCMS's backend_users and backend_user_roles tables (cabana.BackendUser, cabana.BackendUserRole, cabana.BackendUsers): a composite backend guard (a guard another plugin already registered under that name fails cabana.Activate) that accepts an Authorization: Bearer personal API token (cabana.APITokenPrefix / g15_…) or, otherwise, the unchanged admin JWT path (Bearer JWT, then the admin cookie). Personal tokens never come from a cookie or the query string. Login throttling, token refresh and revocation, and the SPA cookie transport are unchanged. Cookie-authenticated requests that change state must carry X-Requested-With: XMLHttpRequest, which blocks cross-site request forgery.
  • Personal API tokens: administrators mint scoped tokens stored in backend_api_tokens (cabana.BackendAPIToken); only the sha256 hash and a four-character hint are kept. Framework scopes are * (full access as the owner on every method) and admin:read (as the owner on GET and HEAD); plugins add scopes through pact.HasTokenScopes. Interactive-only routes (account profile, avatar, token management, refresh and logout) refuse every personal token with details.reason interactive_only. Routes outside cabana.Allows (GET /field-types, POST /markdown/preview) are gated so only a full scope, or admin:read on GET, passes. Plugin routes resolve a backend principal with cabana.ResolveBackend (Bearer only; never the admin cookie).
  • My account: the signed-in administrator opens /account from the user menu and edits their own first name, last name, email and subtitle, and changes their password with the current one (PUT /account, body cabana.AdminAccountRequest). The row is always the token subject, never a request value, and any key outside the request type (login, role, superuser flag, permissions, activation) is a 422 on that key with nothing written. The subtitle is stored where WinterCMS keeps it, under subtitle in the jsonable backend_users.metadata column (cabana.BackendUser.Metadata), and is read with cabana.BackendUser.Subtitle, which treats NULL, [] and malformed metadata as an empty subtitle; saving it keeps every other metadata key. A password change ends every other session through tokens_valid_after and gives a cookie session a fresh cookie. The same screen uploads and removes the administrator's avatar (POST and DELETE /account/avatar): a public attachOne in system_files under WinterCMS's morph name (cabana.BackendUserMorphType, field cabana.BackendUserAvatarField; cabana.BackendUser implements attach.Owner and attach.HasRelations), so avatar rows copied from WinterCMS are picked up as they are. Only jpg, jpeg, png, gif and webp images that pass the image check, of at most cabana.AvatarMaxBytes (2 MiB), are stored, under a server-generated name. Plugins read the picture with cabana.BackendUserAvatar.
  • 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. A body that cannot be encoded is logged and answered with the generic 500 envelope, never a success status with a truncated body.
  • 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, resetting their passwords and managing personal API tokens (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. Refresh refuses personal API tokens (interactive_only).
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 JWT, also when its access lifetime has expired but its refresh window is open, and clear the session cookie; return the signed-in administrator (cabana.AdminProfile, with the subtitle and, when there is one, the avatar URLs as a cabana.AdminAvatar). Logout refuses personal API tokens; /auth/me accepts any valid token.
PUT /account Save the signed-in administrator's own first name, last name, email and subtitle and, with current_password, a new password (cabana.AdminAccountRequest); answers the cabana.AdminProfile. Other keys are a 422. The email must be valid, 6 to 255 characters and not equal, ignoring case, another administrator's email or login. A password needs at least 4 characters, at most 72 bytes and an equal password_confirmation. Interactive session only (personal API tokens are refused).
POST and DELETE /account/avatar Upload the signed-in administrator's avatar (one multipart file_data part: a jpg, jpeg, png, gif or webp image of at most 2 MiB; anything else is a 422 on avatar, a body over the cap a 413) replacing the previous one, or remove it (also 200 when there is none). Both answer the cabana.AdminProfile. Interactive session only.
GET /account/tokens, POST /account/tokens List the caller's personal API tokens (newest first; revoked only with ?revoked=1) and create one (cabana.AdminAPITokenRequest: name, expires, scopes). Create answers 201 with cabana.AdminAPITokenCreated (plaintext token once). Interactive session only.
DELETE /account/tokens/{id} Soft-revoke one of the caller's own active tokens (cabana.AdminAPIToken); 404 otherwise. Interactive session only.
GET /account/tokens/scopes Scopes the caller may assign (cabana.AdminAPITokenScope), including *, admin:read and overlapping plugin scopes. Interactive session only.
GET /navigation, GET /settings Navigation and settings entries the administrator may open; a settings entry's controller names the admin controller a link entry opens and is empty for a singleton form.
GET /field-types The plugin field types (pact.HasFormFieldTypes) sorted by name, each with its element, translatable flag and the versioned URLs of its scripts and styles (cabana.FieldTypesResponse); an empty list when no plugin registers one. The SPA loads them after sign-in. Needs any backend session.
POST /{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload Store one multipart part named file through the Upload of the field's plugin field type and answer 201 {url} (cabana.FieldUploadResponse). {id} 0 is the create form and passes no record; any other id loads the record through the form scope (404 outside it). 403 when the controller does not declare the save the upload belongs to (create for {id} 0, which needs the list's create toolbar button, update otherwise), as the save routes answer. 404 for a field without a plugin type with Upload or whose context does not allow the operation, 413 over http.body_limits.upload_bytes, 422 on the field for a malformed body or a pact.FormFieldError. The body is read and checked whole before Upload runs, so a refused request stores nothing.
GET /settings/{code}/schema, GET and PUT /settings/{code} Settings form schema, values and update.
POST /markdown/preview Render {markdown} through cabana.RenderMarkdown for the form preview and answer {html}; a refused output is a 422 validation_failed on markdown. Needs any backend session.
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 (needs a form and create in toolbar.buttons).
GET, PUT and DELETE /{vendor}/{plugin}/{controller}/{id} Show, update and delete a record (update and delete need a form).
POST /{vendor}/{plugin}/{controller}/bulk-delete Delete a set of records in one transaction; needs delete in toolbar.buttons.
POST /{vendor}/{plugin}/{controller}/bulk/{action} Run a declared bulk action on {ids} in one transaction; answers {message, affected}, 409 for a partial selection.
POST /{vendor}/{plugin}/{controller}/{id}/actions/{action} Run a declared record action with an empty {} body in one transaction; answers {message, fill: {}}, 404 outside the form scope, 409 when the action does not apply.
POST /{vendor}/{plugin}/{controller}/widgets/{field} Run the action of a type: widget field with an optional record_id, the fill snapshot and an optional payload (any JSON, 64 KiB); answers {message, fill, data?}.
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 and POST /{vendor}/{plugin}/{controller}/reorder Load the scoped reorder tree (?partition=) and save it: {partition, nodes: [{id, parent_id, sort_order}]} with every node exactly once, 409 reorder_stale for a changed set. Needs config_reorder.yaml.
GET /{vendor}/{plugin}/{controller}/singleton The id of a pact.AdminSingleton controller's one record, created when absent.
GET .../fields/{field}/dependent-options Choices of a dropdown with dependsOn for the sibling value passed as values[<sibling>], from the controller's pact.DependentOptionsProvider: {applies, options: [{value, label, depth}]} (cabana.AdminDependentOptionsResult). A field that is not a dependsOn dropdown answers 404.
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. A link body may carry pivot values for one id on a relation with a pivot form. On a hasMany, link sets and unlink clears the related record's foreign key.
POST .../{id}/relations/{name}/records Create a related record through the relation's manage form and attach it to the record (a hasMany foreign key is set by the server; a belongsToMany gets its pivot row). Needs create in the view panel's toolbarButtons (403 otherwise).
GET and PUT .../{id}/relations/{name}/records/{child} Show a child (needs update or a view form) and save it through the manage form (needs update).
POST .../{id}/relations/{name}/delete {ids}: delete children through their model (needs delete). A belongsToMany record loses this record's pivot row first. All or nothing.
GET and PUT .../{id}/relations/{name}/pivot/{child} Read and save the pivot form values of one link (needs a pivot form and link or update).
GET and POST .../{id}/relations/{name}/records/{child}/files/{field}, PUT and DELETE .../files/{field}/{file}, POST .../files/{field}/reorder, GET .../files/{field}/{file}/download and /thumb The file routes of a fileupload field in the relation's manage form, keyed by X-Child-Session-Key; {child} 0 is the child being created.
GET .../{id}/files/{field} The files of a type: fileupload field: attached files minus the session's pending removals, plus its pending uploads, in sort_order. {id} 0 is the record being created and needs X-Session-Key.
POST .../{id}/files/{field} Upload one multipart file_data part; it is bound to the X-Session-Key session (required) and attached by the record's next save. Answers 201 with the pending cabana.FileItem.
PUT .../{id}/files/{field}/{file} Save a file's title and description at once; the field must declare useCaption (403 otherwise).
DELETE .../{id}/files/{field}/{file} Remove a file: an attached file is removed by the next save with the same X-Session-Key (required), a pending upload is deleted at once.
POST .../{id}/files/{field}/reorder {ids} in the new order, exactly the field's visible files; they take the existing sort_order values at once. attachMany only (403 otherwise).
GET .../{id}/files/{field}/{file}/download, GET .../{id}/files/{field}/{file}/thumb Stream a protected file or its preview thumbnail; see the protected files note below.

Every relation route accepts {id} 0 for the record being created when the relation is deferrable (a belongsToMany, or a hasMany with a nullable foreign key), the request carries X-Session-Key and the controller declares create: children created, linked, unlinked and deleted there are held against the key and applied by the record's first save, in its transaction, and an ineligible link answers 422 on the relation-manager field. A deferrable relation that declares create needs its related model in some plugin's pact.HasModels list, so deferred:purge can delete abandoned children; boot fails otherwise. Existing belongsToMany managers without context: update therefore appear on create screens.

Every relation child and pivot route loads the record through pact.FormExtendQuery and finds the child with one query that carries the record: a hasMany child by its foreign key, a belongsToMany record by a pivot row. A child of another record is not_found, never forbidden.

Every file route resolves its file with one query scoped to the record (loaded through pact.FormExtendQuery) or to the administrator's own pending uploads: a file of another record is not_found, never forbidden. The save applies the session's file work in its transaction: a pending upload on an attachOne field replaces the file attached before, a deferred removal deletes the attached file, and the blobs of deleted files are removed after commit. After that the save checks maxFiles and a required fileupload field (at least one file) and answers 422 on the field when either fails; the pending work stays for the next attempt.

Protected files (a relation with Public false) never get a public URL. The download and thumb routes serve only is_public false files, with X-Content-Type-Options: nosniff, Cache-Control: private, no-store and Content-Security-Policy: default-src 'none'; sandbox; JPEG, PNG, GIF and WebP are served inline with their type, every other type as an application/octet-stream attachment. The thumb route answers 404 for a file that is not one of those images.

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 (in config_list.yaml, or under preview: in config_form.yaml) and path: stats all 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 "<key>" translates a phrase key in the request locale. record is nil for a list header partial and for a form partial on the create form; a preview header partial is a form partial and always gets its record; 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 walks its type through pointers, slices, arrays, maps, struct fields and the results of its exported methods (templates call methods), and the values held in interface-typed members such as map[string]any. It refuses the controller's own model type, any other GORM model (a struct with a TableName method, a gorm struct tag, gorm.Model or gorm.DeletedAt) and html/template's pre-escaped content types anywhere in that structure, so escaping stays on for every record value. A method that returns an interface is not called, so its run-time result is not checked. 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:

<dl class="summer-stats">
  <div class="summer-stat"><dt class="summer-stat__label">{{ trans "acme.blog::lang.stats.posts" }}</dt><dd class="summer-stat__value">{{ .Data.Total }}</dd></div>
</dl>

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 <dl>.
summer-stat One item of the strip: the value is shown above the label while <dt> 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.
summer-callout A status hint above a preview screen: a borderless block with a 12px radius and 14px 18px padding; long text wraps.
summer-callout--warning, summer-callout--danger The callout's tone: the selection tint with body text, or the soft danger background with danger text.
summer-callout__title The callout's first line, weight 600.
summer-callout__text The callout's second line, weight 400.

Use <dl class="summer-stats"> with one <div class="summer-stat"> per item holding a <dt class="summer-stat__label"> and a <dd class="summer-stat__value">, as in the example above.

A status hint (preview.headerPartial) uses the callout classes. role="status" is on the attribute allowlist; a callout holds no icon, link or button:

<div class="summer-callout summer-callout--warning" role="status">
  <p class="summer-callout__title">{{ trans .Data.Title }}</p>
  <p class="summer-callout__text">{{ trans .Data.Text }}</p>
</div>

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 <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. surf.Router.AdminPrefix reports which case applies, and the serve and route:list commands print it as an admin status line. 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, admin:reset-password and admin:token:* commands.
cabana.BackendAPIToken GORM model of backend_api_tokens (hash and hint only; plaintext is never stored).
cabana.APITokenPrefix The g15_ prefix of a personal API token plaintext.
cabana.ResolveBackend Authenticates a request outside the admin middleware from the Authorization Bearer header only (admin JWT or g15_ token, never the admin cookie), with an optional scope cover check.
cabana.ErrUnauthenticated / cabana.ErrTokenScope Sentinel errors from cabana.ResolveBackend.
cabana.AdminAPIToken / cabana.AdminAPITokenScope / cabana.AdminAPITokenCreated / cabana.AdminAPITokenRequest JSON shapes of the account token API.
cabana.CompileList / cabana.CompileForm Compile a controller's list and form YAML into cached schemas.
cabana.CompileFormWithTypes cabana.CompileForm that also accepts the plugin field types of one plugin ([]pact.FormFieldType), with the boot checks of their aliases, option keys and CheckOptions.
cabana.FieldTypeAssets / cabana.FieldTypesResponse One entry of GET /field-types (name, element, translatable, scripts, styles) and the route's answer.
cabana.AdminFieldTypes Swag annotation of the field types route.
cabana.RecordMeta Meta of a record response: labels of relation fields, actions (show only) and notices (create and update only, omitted when empty).
cabana.FieldNotice One entry of meta.notices: the field, the content locale of a translatable field type's value, and the localized message a field type's Decode reported. At most 10 per field and locale, each cut to 500 characters.
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.Registry.Setting returns singleton settings only, never a controller link.
cabana.CRUDService Schema-projected show, create, update, delete, bulk delete and relation options; cabana.CRUDService.BulkAction runs a declared bulk action on a scoped, locked id set.
cabana.BulkAction One entry of a list schema's bulkActions: name, localized label and optional confirm text.
cabana.BulkActionResult Answer of the bulk action route: the localized message and the affected count.
cabana.AdminBulkAction Swag annotation of the bulk action route.
cabana.AdminDependentOptionsResult / cabana.AdminDependentOption Answer of the dependent-options route: applies and the options, each with value, localized label and depth.
cabana.AdminDependentOptions Swag annotation of the dependent-options route.
cabana.ListReorder / cabana.ListReorderPartition The list schema's reorder object: the page title and the partition column and label.
cabana.AdminReorderTree / cabana.AdminReorderNode / cabana.AdminReorderPartition / cabana.AdminReorderPartitionOption Answer of the reorder routes: title, partition (label, options, value) and the nodes with id, parent_id, sort_order and name.
cabana.AdminReorderRequest / cabana.AdminReorderNodeInput Body of the reorder save.
cabana.AdminReorderGet / cabana.AdminReorderSave Swag annotations of the reorder routes.
cabana.AdminSingletonResult / cabana.AdminSingletonRecord Answer and swag annotation of the singleton route.
cabana.RepeaterForm The form of a type: repeater field in a form schema: the fields of one row.
cabana.FormPreview The preview object of a form schema: present when the form has a preview screen; headerPartial names its status hint partial.
cabana.RecordAction One entry of a record response's meta.actions: name, localized label and optional confirm text.
cabana.CRUDService.RecordAction Runs a declared record action on one scoped, locked record.
cabana.AdminRecordAction Swag annotation of the record action route.
cabana.ExecuteList Runs an allowlisted, paginated list query for a controller.
cabana.RelationService Linked, candidate, link and unlink operations of relation managers, child create, show, update and delete (CreateChild, ShowChild, UpdateChild, DeleteChildren) and pivot values (ShowPivot, UpdatePivot); its SessionKey makes record id 0 the record being created in that session.
cabana.SettingsService Reads and transactionally updates singleton settings rows.
cabana.FieldRelationProvider / cabana.FieldRelationContract Controller-supplied bindings for type: relation form fields. WritableForeignKey on a belongsTo contract makes a protected foreign key writable through that field.
cabana.RelationLock The related ids of one relation field the requesting administrator may not add or remove, and the message of the 403.
cabana.RelationLockProvider Controller capability that returns the cabana.RelationLock of a relation field per request; enforced on create and update.
cabana.AdminRelationContractProvider / cabana.RelationContract Controller-supplied bindings for relation managers.
cabana.RelationBelongsToMany / cabana.RelationHasMany The two RelationContract.Kind values; an empty Kind is a belongsToMany.
cabana.AdminRelationChildCreate / cabana.AdminRelationChildShow / cabana.AdminRelationChildUpdate / cabana.AdminRelationChildDelete / cabana.AdminRelationPivotShow / cabana.AdminRelationPivotUpdate Swag annotations of the relation child and pivot routes.
cabana.RelationMutationInput Body of the link and unlink routes: IDs and, for a link of one id, Pivot form values.
cabana.AdminRelationLinkRequest Documented body of the link route: ids and the optional pivot object.
cabana.BackendUser / cabana.BackendUserRole / cabana.BackendUsers GORM models of the backend user tables and the principal loader used by the guard.
cabana.BackendUser.Subtitle The administrator's subtitle, backend_users.metadata.subtitle; empty for NULL, [], malformed metadata or a non-string value.
cabana.AdminProfile The signed-in administrator returned by GET /auth/me and the My account routes: id, login, email, names, subtitle, superuser flag and role.
cabana.AdminAccountRequest Body of PUT /account: email, first_name, last_name, subtitle and the optional current_password, password and password_confirmation. These are the only accepted keys.
cabana.AdminAccountUpdate / cabana.AdminAccountAvatarUpload / cabana.AdminAccountAvatarRemove Swag annotations of the My account routes.
cabana.AdminAvatar The avatar of a cabana.AdminProfile: the public url and the 250x250 crop thumb_url (left out when the thumbnail cannot be made).
cabana.AvatarMaxBytes Largest avatar image the upload route accepts, 2 MiB; http.body_limits.upload_bytes still caps the request.
cabana.BackendUserAvatar Returns a backend user's avatar attach.File (the newest avatar row), or nil when there is none; File.URL and File.Thumb give its public URLs.
cabana.BackendUserMorphType / cabana.BackendUserAvatarField The avatar's system_files.attachment_type (WinterCMS's Backend\Models\User) and field (avatar).
cabana.BackendUser.MorphName / cabana.BackendUser.AttachRelations attach.Owner and attach.HasRelations of the backend user: one public attachOne avatar.
cabana.Allows Checks a principal against required permission codes.
cabana.TxFromContext The transaction a write route is running in, from the context of a lifecycle hook or scope.
cabana.VirtualFieldsFromContext The submitted values of the form's virtual fields (pact.FormVirtualFields), from the context of a Form hook during a create or update; a copy, keyed by field name.
cabana.FieldWrite One value a save wrote for a plugin field type: the content Locale (translatable types), Default (written to the host column) and the decoded Value.
cabana.FieldWritesFromContext The values a create or update save writes for the form's plugin field types, per field and locale, from the context of a Form hook; untouched values are not listed. A copy; false outside a save.
cabana.WithFieldWrites Puts field writes on a context as a save does, for tests of Form hooks that read cabana.FieldWritesFromContext.
cabana.FieldUploadResponse / cabana.AdminFieldUpload Answer (url) and swag annotation of the field upload route.
cabana.PermissionOption One permission a type: permissioneditor field offers: code, label, optional tab and comment, and Locked.
cabana.PermissionEditorProvider Controller capability behind a type: permissioneditor field: AdminPermissionOptions, AdminPermissionValues and AdminSetPermissionValues.
cabana.FormMeta Form (and optional list) schema meta: Locale is the UI locale of the response; EnabledLocales lists content locales for ML fields when a cabana.TranslationWriter is published, default locale first and then the other enabled codes in the writer's order (json:"enabledLocales,omitempty" so list schemas omit the key).
cabana.TranslationWriter Optional plugin-published service that persists non-default locale values during a host save and reads exact stored translations through TranslatedExact. Cabana never imports a translate plugin; writes run only inside the permissioned, scoped save transaction after the host row has a primary key, including relation-child records served by cabana.RelationService; Show/save hydration uses hydrateMLRecord.
cabana.TranslationWriter.TranslatedExact Returns the stored value for a field in a locale without D-11 fallback; ok is false when the non-default key is missing. The default locale still reads the host column.
hydrateMLRecord Replaces host scalars of declared mltext/mltextarea/mlmarkdown fields on a Show/save record — host CRUD and relation-child create, update and show — with a locale map for every enabled code. Not used from list row projection.
cabana.RenderMarkdown Converts markdown source to HTML with the pinned goldmark engine without unsafe HTML; leftover script or iframe tags, event handlers, or javascript, vbscript or data URLs are rejected.
cabana.FieldPreset A text or mltext field's preset in the form schema: the source field and the type, slug or exact.
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.ForbiddenError A write controller code refuses: a hook or an action returns it and the route answers 403 forbidden with its localized Message and Details; the transaction is rolled back.
cabana.AdminActionRequest Body of an action route: optional record_id, the widget's values and an optional payload (any JSON value, at most 64 KiB, widget route only). Unknown keys are refused.
cabana.AdminActionResult Answer of an action route: the localized message, the filtered fill object and, when the action returned one, data exactly as returned.
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.SessionKeyHeader X-Session-Key, the header that carries the form session key of deferred file and relation work.
cabana.ChildSessionKeyHeader X-Child-Session-Key, the header that carries a relation child form's own session key for its file uploads.
cabana.RecordInput A create or update body (Body) and the form session key (SessionKey) whose file bindings the save applies.
cabana.FileItem One file of a fileupload field: id, name, size, content type, title, description, sort_order, pending, and url/thumb_url for public relations.
cabana.ThumbOptions A fileupload field's thumbOptions (the preview thumbnail mode).
cabana.FileMutationResult Answer of the file removal route: removed.
cabana.AdminFileCaptionRequest Body of the file caption route: optional title and description. Unknown keys are refused.
cabana.AdminMarkdownPreviewRequest Body of the markdown preview route: the markdown source. Unknown keys are refused.
cabana.AdminMarkdownPreviewResult Answer of the markdown preview route: the html that cabana.RenderMarkdown produced.
cabana.AdminMarkdownPreview Swag annotation of the markdown preview route.
cabana.AdminFileList / cabana.AdminFileUpload / cabana.AdminFileUpdate / cabana.AdminFileRemove / cabana.AdminFileReorder / cabana.AdminFileDownload / cabana.AdminFileThumb Swag annotations of the file routes.
cabana.AdminRelationChildFileList / cabana.AdminRelationChildFileUpload / cabana.AdminRelationChildFileUpdate / cabana.AdminRelationChildFileRemove / cabana.AdminRelationChildFileReorder / cabana.AdminRelationChildFileDownload / cabana.AdminRelationChildFileThumb Swag annotations of the relation child file routes.
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.
http.body_limits.upload_bytes none Read from the HTTP configuration: caps the body of a file upload (together with the field's maxFilesize plus 64 KiB), and no fileupload field may declare a maxFilesize whose file plus 64 KiB of multipart framing exceeds it. Without it the cap is the field's limit, or 128 MiB.
http.body_limits.default_bytes none Read from the HTTP configuration: caps the JSON bodies of create, update, settings, relation link/unlink, bulk delete, file caption and reorder, and relation child routes (1 MiB when not set; 413 payload_too_large past it).

The backend user, role, token blacklist and personal API token tables (backend_users, backend_user_roles, backend_jwt_blacklist, backend_api_tokens) are created by lagoon.BackendAdminMigrations, which the migrate command runs.

CLI commands

These 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 (required), --login (defaults to the lower-cased email), --role <code>, --superuser, --password (deprecated) Creates an activated backend administrator. The password is read from a hidden prompt, or from stdin when it is not a terminal.
admin:reset-password <identifier> (login or email), --password (deprecated) Sets a new password, read like the one of admin:create, and revokes every JWT issued before the reset. Personal API tokens are unchanged.
admin:token:create <identifier>, --name, --scope (repeatable), --expires (30d, 90d, 365d, never, or YYYY-MM-DD) Creates a personal API token for the named administrator and prints exactly the plaintext and a newline on stdout.
admin:token:list [<identifier>], --revoked Lists tokens (hint only, never the hash or plaintext).
admin:token:revoke <id> Soft-revokes any user's active token by id.
admin:token:scopes none Lists framework and plugin scopes available for tokens.
./bin/acme admin:create --email admin@example.com --superuser
printf '%s\n' "$ADMIN_PASSWORD" | ./bin/acme admin:reset-password admin@example.com
TOKEN=$(./bin/acme admin:token:create admin@example.com --name agent --scope blog)

--password still works, but it prints a deprecation warning: a value on the command line is visible in the process list and stays in the shell history.

Dependencies

  • SummerCMS modules: backpack, boardwalk, bonfire, bouncer, lagoon, pact, party, phrasebook, seaglass (sanitizes richeditor values), towel.
  • 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, unicode/utf8.
  • Tests additionally use github.com/testcontainers/testcontainers-go and its modules/postgres package, and signpost for the dependent dropdown fixture.

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