# SummerCMS documentation Source: /docs/index.html SummerCMS is a content management framework for Go, inspired by WinterCMS. SummerCMS keeps what makes WinterCMS productive (plugins that extend each other, YAML-driven admin forms and lists, console scaffolding) and compiles an application into a single Go binary. The framework modules, the application's plugins, the embedded admin SPA and the console commands all ship as one executable. Start with [Installation](/docs/setup/installation.md) to set up the toolchain and the `summer` CLI. If you know WinterCMS, read [Coming from WinterCMS](/docs/setup/coming-from-wintercms.md) for a map of its concepts to SummerCMS. Then follow [Porting a plugin](/docs/setup/porting-a-plugin.md), which moves a WinterCMS plugin to SummerCMS step by step. The API reference section has one page per framework module. ## Where to start - [Setup](/docs/setup/introduction.md): what SummerCMS is, installation, configuration, the move from WinterCMS and a [plugin porting walkthrough](/docs/setup/porting-a-plugin.md). - [Architecture](/docs/architecture/introduction.md): the single binary, Go modules, the application lifecycle, the request lifecycle and [performance and scaling](/docs/architecture/performance-and-scaling.md) compared with PHP-FPM. - [Plugins](/docs/plugins/registration.md): registering a plugin, scheduling, extending other plugins and testing. - [Backend](/docs/backend/admin-controllers.md): admin controllers, forms, lists, relations, users, settings and the admin SPA. - [Database](/docs/database/models.md): models, migrations, queries, relations, casts, attachments and transactions. - [Services](/docs/services/configuration.md): configuration, events, routing, authentication, mail, jobs, realtime, search and the other services, plus what SummerCMS does not provide for the frontend. - [Console](/docs/console/introduction.md): the `summer` tool, the application binary's commands and writing your own. # Introduction Source: /docs/setup/introduction.html What SummerCMS is, who it is for, how its headless model works and how this documentation is organised. SummerCMS is a content management framework for Go, inspired by WinterCMS. It keeps what makes WinterCMS productive (plugins that extend each other, backend lists and forms described in YAML, models, migrations and console scaffolding) and compiles an application into a single binary. ## Who it is for SummerCMS is for developers who build content-driven applications and APIs the WinterCMS way and want a compiled, typed backend. If you already know WinterCMS, most concepts carry over: a plugin still has an ID, requires other plugins, registers and boots, ships migrations and admin YAML, and adds console commands. Start with [Coming from WinterCMS](/docs/setup/coming-from-wintercms.md) for a concept-by-concept map. ## The headless model SummerCMS is headless. An application serves: - a JSON API, declared route by route by its plugins; - an admin area, a compiled single-page application embedded in the binary, driven by each plugin's `fields.yaml` and `columns.yaml`; - console commands for migrations, workers, the scheduler and your own tasks. It does not render public pages. There are no themes, CMS pages, layouts, components or AJAX framework. Build the public site as a separate frontend application that calls the JSON API and, for live updates, subscribes to realtime channels. ## One binary Plugins are Go packages compiled into the application at build time. The `summer` tool reads the application's `summer.yaml` manifest, generates the plugin imports and builds one executable. Nothing is loaded or installed at runtime, so what you tested is exactly what you deploy. The data layer supports PostgreSQL only, and SummerCMS targets Go 1.27. ## How these docs are organised - **Setup** covers installation, configuration and the move from WinterCMS. - **Architecture** explains the single binary, Go modules, the application lifecycle and how a request reaches your code. - **Plugins** covers registering a plugin, scheduling, extending other plugins and testing. - **Console** lists the `summer` tool's commands and the commands of every application binary, and shows how to write your own. - **API reference** has one page per framework module. Continue with [Installation](/docs/setup/installation.md). # Installation Source: /docs/setup/installation.html Install the Go toolchain, PostgreSQL and the summer CLI, then build, configure, migrate and serve your first SummerCMS application. SummerCMS is a Go module. An application requires it, lists its plugins in a `summer.yaml` manifest and builds everything into one binary with the `summer` CLI. This page installs the tool, then builds and runs `examples/hello`, the small reference application in the framework repository. ## Requirements - Go 1.27. - PostgreSQL 15 or newer for any application that uses the data layer. - Docker, only for the integration tests that start PostgreSQL or Mailpit containers. You do not need Node.js to build an application or these docs. It is needed only when you work on the admin SPA itself. ## Install the summer CLI Clone the framework repository, then install the `summer` tool from its root: ```sh go install ./cmd/summer summer --help ``` `go install` writes the binary to `$(go env GOPATH)/bin`, usually `~/go/bin`. If `summer --help` reports `command not found`, that directory is not on your `PATH`. Add it for the current shell, and to your shell profile to keep it: ```sh export PATH="$(go env GOPATH)/bin:$PATH" ``` The tool builds and watches applications, scaffolds plugins, models, migrations and admin controllers, and builds this documentation. Check that the framework compiles and its unit tests pass: ```sh go vet ./... go test -short ./... ``` `go test -short` skips the tests that need Docker. Run `go test ./...` without `-short` when Docker is available. ## Check your install Console commands in SummerCMS are plain `bonfire.Command` values: a name in `namespace:verb` form, its arguments and flags, and a run function that reads input and writes output. The `summer` tool and every application binary are built from such values, and `bonfire.Call` runs one in-process, which is how tests call commands. This example comes from the framework's own tests, so it compiles and runs whenever you run `go test ./...`: ```go commands := []bonfire.Command{{ Name: "acme:greet", Description: "Greet someone by name", Args: []bonfire.Arg{{Name: "name", Required: true}}, Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error { name, _ := in.Argument("name") out.Printf("Hello, %s\n", name) return nil }, }} if err := bonfire.Call(context.Background(), commands, "acme:greet", []string{"blog"}, os.Stdout); err != nil { fmt.Println(err) } // Output: Hello, blog ``` If the tests above pass, this example ran and printed `Hello, blog`. See the [bonfire](/docs/api/bonfire.md) reference for flags, prompts and styled output. ## Build the example application `examples/hello` has three compiled plugins. From the framework root, build it with `summer build`, which generates `plugins.gen.go` and `main.go` from `summer.yaml` and writes the binary to `bin/hello`: ```sh cd examples/hello summer build ./bin/hello --help ./bin/hello greeter:hello ./bin/hello key:generate ``` `greeter:hello` prints the example's layered configuration and `key:generate` prints a fresh application key. Neither needs a database. ## Create the database The commands that touch data open PostgreSQL. Create a database for the example: ```sql CREATE DATABASE hello; ``` ## Configure the application Configuration comes from YAML files in `config/`. Any key can be overridden with a `SUMMER_` environment variable, in which a double underscore separates path segments: `SUMMER_DATABASE__DSN` sets `database.dsn`. [Configuration](/docs/setup/configuration.md) explains the layers. `serve` and `route:list` need request body limits, and surf reads them as numbers, which the string-valued environment overlay cannot supply. Create `config/http.yaml` in `examples/hello`: ```yaml body_limits: default_bytes: 1048576 upload_bytes: 10485760 ``` Then point the binary at the database, give it the application key and an uploads bucket. Replace the `` markers with your own values, and never commit them: ```sh export SUMMER_DATABASE__DSN='postgres://acme:@127.0.0.1:5432/hello?sslmode=disable' export SUMMER_APP__KEY='' export SUMMER_STORAGE__UPLOADS__BUCKET_URL='mem://' ``` ## Migrate and serve Run the migrations, list the routes and start the server on the loopback address: ```sh ./bin/hello migrate ./bin/hello route:list ./bin/hello serve --addr 127.0.0.1:8080 curl http://127.0.0.1:8080/items/1 ``` From the application directory, `summer migrate`, `summer migrate:status` and `summer serve` run the same commands through the built binary, building it first when it is missing. > [!WARNING] > Known issues in the current framework: > > - `examples/hello` ships no `http.body_limits` configuration, so `serve` and `route:list` fail with `surf: config http.body_limits.default_bytes is required` until you add `config/http.yaml` as shown above. The same gap makes the example's `TestTypedItemRoute` fail. > - The committed `examples/hello/main.go` is older than what `summer build` generates now, so building the example leaves that file modified. Restore it with `git checkout -- examples/hello/main.go` if you do not intend to commit it. ## Next steps - Read [Coming from WinterCMS](/docs/setup/coming-from-wintercms.md) if you are porting a WinterCMS plugin. - Read the [Architecture introduction](/docs/architecture/introduction.md) to see how the pieces fit. - Scaffold your own plugin with `summer make:plugin` as described in [Plugin registration](/docs/plugins/registration.md). # Configuration Source: /docs/setup/configuration.html Configure an application with YAML files, per-environment directories, plugin defaults and SUMMER_ environment variables, and set the keys it needs. WinterCMS keeps configuration in `config/*.php` files, per-environment directories and `.env`. SummerCMS keeps the same shape with YAML files, merged by [compass](/docs/api/compass.md) into one tree that you read with dot paths such as `app.name`. ## The config directory Each YAML file in the application's `config/` directory is a section named after the file, so `config/app.yaml` provides the `app.*` keys and `config/http.yaml` provides `http.*`: ```yaml # config/app.yaml name: Acme url: http://127.0.0.1:8080 timezone: Europe/Warsaw ``` Files for one environment go in `config/env//` with the same naming, and override the base files. The environment comes from `SUMMER_ENV` and defaults to `production`, so a development setup sets `SUMMER_ENV=development` and keeps its overrides in `config/env/development/`. ## Plugin defaults A plugin ships its own defaults embedded in the binary through `pact.HasConfig`. Its `config/config.yaml` becomes `.`, so the `posts_per_page` key of `acme.blog` is read as `acme.blog.posts_per_page`. WinterCMS writes the same key as `acme.blog::posts_per_page`. The application overrides a plugin default like any other key: put `posts_per_page` under `blog:` in `config/acme.yaml`. ## Environment variables Any key can be overridden with an environment variable. Take the dot path, replace each dot with a double underscore, upper-case it and prefix `SUMMER_`: | Key | Variable | |-----|----------| | `database.dsn` | `SUMMER_DATABASE__DSN` | | `app.key` | `SUMMER_APP__KEY` | | `admin.jwt.secret` | `SUMMER_ADMIN__JWT__SECRET` | A `.env` file next to the `config/` directory supplies `KEY=VALUE` lines for variables that are not already set in the real environment. Keep secrets in the environment or in `.env`, never in committed YAML. Environment values are strings. Keys that must be numbers, such as `http.body_limits.default_bytes`, belong in a YAML file. The layers merge in this order, each overriding the ones before it: plugin defaults, `config/*.yaml`, `config/env//*.yaml`, `SUMMER_` variables, then runtime overrides that a command saved to `config/env//overrides.yaml`. ## Keys an application sets These are the keys most applications set. Each module's reference page lists all of its keys and their defaults. | Key | Purpose | Reference | |-----|---------|-----------| | `database.dsn` | PostgreSQL connection string. Required by every command that opens the database. | [lagoon](/docs/api/lagoon.md) | | `app.key` | Base64 encoding of 32 random bytes, used to encrypt columns. Generate it with `key:generate`. | [lagoon](/docs/api/lagoon.md) | | `app.timezone` | Timezone of scheduled commands. Defaults to UTC. | [conga](/docs/api/conga.md) | | `app.locale`, `app.fallback_locale` | Default and fallback translation locales. | [phrasebook](/docs/api/phrasebook.md) | | `http.body_limits.default_bytes`, `http.body_limits.upload_bytes` | Request body limits. Required by `serve` and `route:list`. | [surf](/docs/api/surf.md) | | `http.cors.*`, `http.trusted_proxies` | CORS and the proxies whose forwarded client IP is trusted. | [surf](/docs/api/surf.md) | | `storage.uploads.bucket_url` | Uploads bucket, `file://` or `mem://`. Required by `serve`. | [lagoon](/docs/api/lagoon.md) | | `admin.jwt.secret` | Secret for admin tokens. Required as soon as a plugin registers an admin controller. | [cabana](/docs/api/cabana.md) | | `backend.uri` | Path the admin is served under. Defaults to `/backend`. | [cabana](/docs/api/cabana.md) | | `mail.driver`, `mail.from`, `mail.smtp.*` | Mail delivery: `memory`, `log` or `smtp`. | [postcard](/docs/api/postcard.md) | | `queue.work_in_serve`, `queue.queues.*` | Whether `serve` runs the job worker, and workers per queue. | [conga](/docs/api/conga.md) | | `realtime.driver`, `realtime.centrifugo.*` | The realtime driver and its Centrifugo settings. | [lighthouse](/docs/api/lighthouse.md) | | `search.driver`, `search.typesense.*` | The search engine and its Typesense settings. | [beachcomber](/docs/api/beachcomber.md) | | `push.enabled`, `push.public_key`, `push.private_key` | Web Push with VAPID keys. | [flare](/docs/api/flare.md) | > [!NOTE] > The documentation checks every identifier, link and command name it shows against the code, but not configuration keys. The keys on this page are reviewed by hand; the module reference pages are the authority. ## Reading configuration in a plugin Plugins read configuration from `backpack.App.Config`, a `compass.Config`. Use `compass.Config.String`, `compass.Config.Int` and `compass.Config.Bool` for single values, which return the zero value for a missing key, `compass.Config.Has` to tell a missing key from a zero value, and `compass.Config.LoadSection` to decode a whole section into a struct. # Coming from WinterCMS Source: /docs/setup/coming-from-wintercms.html Map WinterCMS plugins, models, backend controllers, routes, events and commands to their SummerCMS equivalents, and see what is not provided. SummerCMS keeps the parts of WinterCMS that make a plugin developer productive. Plugins still declare what they add to the application and extend each other through events and shared services. Backend lists and forms are still described in `fields.yaml` and `columns.yaml`. Models, migrations, console commands and scaffolding keep their WinterCMS shape, so a plugin ports file by file. What changes is everything that depends on PHP at runtime. Plugins are Go packages compiled into one binary, so there is no plugin directory scanned at boot and no runtime autoloading. Magic methods, dynamic properties and behaviours give way to Go interfaces and composition. SummerCMS is headless: it serves a JSON API and the admin SPA, and the frontend is a separate application that calls that API. This page maps the concepts. To see them applied to one plugin from start to finish, follow [Porting a plugin](/docs/setup/porting-a-plugin.md), which takes an `acme/blog` plugin with a model, migrations, a route, a backend controller and a console command to SummerCMS. To see how the single-binary process model changes speed, memory and scaling compared with PHP-FPM, read [Performance and scaling](/docs/architecture/performance-and-scaling.md). ## Concept map Each row names the WinterCMS concept, the SummerCMS identifiers that replace it, and where to read more: the guide page first, then the module reference. | WinterCMS | SummerCMS | Where | |-----------|-----------|-------| | `Plugin.php` with `pluginDetails`, `register` and `boot` | A type implementing `party.Plugin` (`party.Plugin.ID`, `party.Plugin.Requires`, `party.Plugin.Register`, `party.Plugin.Boot`), registered from `init` with `party.Register` | [Plugin registration](/docs/plugins/registration.md), [party](/docs/api/party.md) | | `$require` plugin dependencies | `party.Plugin.Requires`; `party.Activate` orders plugins so each comes after the ones it requires | [Plugin registration](/docs/plugins/registration.md), [party](/docs/api/party.md) | | `registerPermissions`, `registerNavigation` | `pact.HasPermissions` returning `pact.Permission` values, `pact.HasNavigation` returning `pact.NavigationItem` values | [Users and permissions](/docs/backend/users-and-permissions.md), [pact](/docs/api/pact.md) | | `version.yaml` and the `updates/` directory | `pact.HasMigrations` returning an ordered gormigrate set; `lagoon.Migrate` runs every plugin's set and `lagoon.RollbackLast` undoes the last one | [Migrations](/docs/database/migrations.md), [lagoon](/docs/api/lagoon.md) | | Eloquent models | GORM structs with lagoon helpers: `lagoon.Fill` for mass assignment, `lagoon.Validate` for rules, `lagoon.Jsonable` for JSON columns, `lagoon.Page` for pagination | [Models](/docs/database/models.md), [Casts and validation](/docs/database/casts-and-validation.md), [lagoon](/docs/api/lagoon.md) | | `fields.yaml` and `columns.yaml` | The same YAML, embedded in the plugin through `pact.AdminAssets` and compiled at boot into a `cabana.CompiledController` | [Forms](/docs/backend/forms.md), [Lists and filters](/docs/backend/lists-and-filters.md), [cabana](/docs/api/cabana.md) | | Backend controllers with the Form, List and Relation behaviours | A `pact.AdminController` returned from `pact.HasAdminControllers`; the generic admin API replaces the behaviours, and hooks such as `pact.FormBeforeCreate` and `pact.ListExtendQuery` replace behaviour overrides | [Admin controllers](/docs/backend/admin-controllers.md), [cabana](/docs/api/cabana.md) | | `routes.php` | `pact.HasRoutes`, declaring routes on a `pact.Router` with groups, middleware names and `pact.Router.Where` constraints | [Routing](/docs/services/routing.md), [surf](/docs/api/surf.md) | | Route middleware | Named middleware of type `pact.Middleware`, registered through `pact.HasMiddleware` | [Routing](/docs/services/routing.md), [surf](/docs/api/surf.md) | | `config/*.php`, `.env` and `Config::get` | `compass.Config` with per-environment directories and `SUMMER_` overrides; plugin defaults through `pact.HasConfig` | [Configuration](/docs/services/configuration.md), [compass](/docs/api/compass.md) | | `lang/` files and `Lang::get` | `pact.HasLang` for plugin catalogs, read through `phrasebook.Translator.Get` and `phrasebook.Translator.Choice` | [Localization](/docs/services/localization.md), [phrasebook](/docs/api/phrasebook.md) | | `Event::listen` and `Event::fire` | `festival.Bus.Listen` and `festival.Bus.Fire` on `backpack.App.Events`, keyed by the event's Go type | [Events](/docs/services/events.md), [festival](/docs/api/festival.md) | | `App::make` and singleton bindings | `backpack.App.Publish` and `backpack.App.Lookup`, keyed by type | [Extending plugins](/docs/plugins/extending.md), [backpack](/docs/api/backpack.md) | | Artisan commands and `registerConsoleCommand` | `bonfire.Command` values returned from `pact.HasCommands` | [Writing commands](/docs/console/writing-commands.md), [bonfire](/docs/api/bonfire.md) | | Queued jobs | `pact.HasJobs` with jobs built by `conga.Job`, dispatched with `conga.Manager.Dispatch` inside the caller's transaction | [Queued jobs](/docs/services/jobs.md), [conga](/docs/api/conga.md) | | `registerSchedule` and the scheduler | `pact.HasSchedule` returning `pact.ScheduledCommand` entries with a `pact.Cadence` | [Task scheduling](/docs/plugins/scheduling.md), [pact](/docs/api/pact.md) | | Mail templates in `views/mail` | `pact.HasMailTemplates`, sent through `postcard.Mailer` | [Mail](/docs/services/mail.md), [postcard](/docs/api/postcard.md) | | Settings models and `registerSettings` | `pact.HasSettings` returning `pact.SettingsItem` entries | [Settings](/docs/backend/settings.md), [cabana](/docs/api/cabana.md) | | Laravel broadcasting | `lighthouse.Publisher` drivers and models that implement `lighthouse.Broadcastable` | [Realtime](/docs/services/realtime.md), [lighthouse](/docs/api/lighthouse.md) | | Laravel Scout search | Models that implement `beachcomber.Searchable`, synced after commit | [Search](/docs/services/search.md), [beachcomber](/docs/api/beachcomber.md) | | The Laravel HTTP client | `fetchguard.Fetch` with a `fetchguard.Policy` that blocks private addresses and limits size and time | [Outbound HTTP](/docs/services/outbound-http.md), [fetchguard](/docs/api/fetchguard.md) | ## What is not provided SummerCMS does not port the WinterCMS frontend or the PHP helpers that Go already covers. Do not look for these when you port a plugin: | WinterCMS | SummerCMS | |-----------|-----------| | CMS pages, themes, layouts and partials | Not provided. The frontend is a separate application that calls the JSON API; see [Frontend and AJAX](/docs/services/frontend-and-ajax.md). | | Components | Not provided. Write an HTTP handler and declare its route through `pact.HasRoutes`; see [Frontend and AJAX](/docs/services/frontend-and-ajax.md). | | The AJAX framework and Snowboard | Not provided. The frontend calls the JSON API and subscribes to realtime channels; see [Frontend and AJAX](/docs/services/frontend-and-ajax.md). | | The media manager | Not provided. Store uploads as model attachments; see [Attachments](/docs/database/attachments.md). | | Import and export in backend lists | Not provided. | | Record sorting (the Reorder behaviour) | Not provided. | | Collections | Not provided. Use Go slices and the `slices` and `maps` packages. | | Behaviours and dynamic class extension | Not provided. Use Go interfaces and composition. | | Cache | Not provided. Use the Go standard library or a service another plugin publishes. | | Session | Not provided. The API is stateless and authenticates with tokens; see [Frontend and AJAX](/docs/services/frontend-and-ajax.md). | ## Plugin.php in Go A WinterCMS plugin registration class for `Acme\Blog` looks like this: ```php 'Blog', 'author' => 'Acme']; } public function registerPermissions() { return [ 'acme.blog.access_posts' => ['tab' => 'Blog', 'label' => 'Manage posts'], ]; } } ``` The SummerCMS plugin is a Go type. The four `party.Plugin` methods replace the plugin details, `$require`, `register` and `boot`, and each extra capability is one more interface, here `pact.HasPermissions`: ```go package party_test import ( "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/pact" ) // BlogPlugin is the acme.blog plugin: the Go form of a WinterCMS Plugin.php. // A real plugin package also registers it from init with // party.Register(&BlogPlugin{}). type BlogPlugin struct{} // The optional capabilities the plugin opts into, checked at compile time. var _ pact.HasPermissions = (*BlogPlugin)(nil) // ID is the plugin identifier in vendor.plugin form. func (p *BlogPlugin) ID() string { return "acme.blog" } // Requires lists the plugins that must register and boot first ($require). func (p *BlogPlugin) Requires() []string { return []string{"acme.user"} } // Register runs before any plugin boots: publish services here. func (p *BlogPlugin) Register(app *backpack.App) error { return nil } // Boot runs after every plugin registered: listen to events and look up // services other plugins published. func (p *BlogPlugin) Boot(app *backpack.App) error { return nil } // Permissions replaces registerPermissions(). func (p *BlogPlugin) Permissions() []pact.Permission { return []pact.Permission{ {Code: "acme.blog.access_posts", Tab: "Blog", Label: "Manage posts"}, } } ``` The type satisfies `party.Plugin`, which this example checks every time `go test ./...` runs: ```go var p party.Plugin = &BlogPlugin{} fmt.Println(p.ID()) // Output: acme.blog ``` Plugin IDs are lower case in `vendor.plugin` form, so `Acme.User` becomes `acme.user`. The application does not scan for plugins: it lists their IDs in its `summer.yaml` manifest, and `summer build` compiles them in. See [Installation](/docs/setup/installation.md) to build your first application. # Porting a plugin Source: /docs/setup/porting-a-plugin.html Take a WinterCMS acme/blog plugin with a model, migrations, a route, a backend controller and an artisan command to a compiled SummerCMS plugin, step by step. This walkthrough ports a small WinterCMS plugin, `Acme.Blog`, to SummerCMS. The plugin has what most real plugins have: a registration class, a `Post` model, `version.yaml` updates, a `routes.php` API endpoint, a backend `Posts` controller with its YAML, and an artisan command. Each section shows the WinterCMS file first and the SummerCMS file that replaces it. The name `acme/blog` is a neutral example. The SummerCMS code on this page is not a sketch: every Go and YAML block is a copy of a file under `docs/examples/blog` in the framework repository, a compiled plugin whose tests activate it, serve its route, run its command and run its migrations up and down against PostgreSQL. Read [Coming from WinterCMS](/docs/setup/coming-from-wintercms.md) first for the map of concepts. ## Scaffold it yourself Every file of the plugin starts as scaffolder output. From the application directory, these commands produce the same file layout as `docs/examples/blog`, which a test in the framework checks: ```sh summer make:plugin acme.blog summer make:model acme.blog Post summer make:migration acme.blog AddPublishedAt summer make:admin-controller acme.blog Posts summer make:command acme.blog Publish summer plugin:add plugins/blog summer build ``` | Command | Writes | |---------|--------| | `summer make:plugin acme.blog` | `plugins/blog` as its own Go module: `plugin.go`, `routes.go`, `registry.gen.go`, `go.mod`, `config/config.yaml`, `lang/en/lang.yaml`, `views/mail/welcome.htm` and a `doc.go` in `classes`, `console`, `controllers`, `jobs`, `middleware`, `models` and `updates` | | `summer make:model acme.blog Post` | `models/post.go` and `updates/_create_acme_blog_posts.go` | | `summer make:migration acme.blog AddPublishedAt` | `updates/_add_published_at.go` | | `summer make:admin-controller acme.blog Posts` | `controllers/posts.go`, `controllers/posts/config_form.yaml`, `controllers/posts/config_list.yaml`, `models/posts/fields.yaml` and `models/posts/columns.yaml` | | `summer make:command acme.blog Publish` | `console/publish.go` | | `summer plugin:add plugins/blog` | The plugin in `summer.yaml`, a `require` and a local `replace` in the application's `go.mod`, and the directory in `go.work` | | `summer build` | `bin/acme`, the application binary with the plugin compiled in | Each `make:` command also rewrites `registry.gen.go` and runs `go mod tidy` in the plugin. The sections below fill in what each generated file leaves empty. See [Scaffolding](/docs/console/scaffolding.md) for every option of these commands. The copy in the framework repository has no `go.mod`, because it is a package of the framework module so that the framework's own `go test ./...` covers it. A plugin you scaffold is a module of its own, and its `go.mod` starts like this for an application whose module is `example.com/acme` (indirect requirements left out): ```text module example.com/acme/plugins/blog go 1.27.0 toolchain go1.27.0 require ( git.golem15.com/golem15/summercms v0.0.0 github.com/go-gormigrate/gormigrate/v2 v2.1.7 gorm.io/gorm v1.31.2 ) replace git.golem15.com/golem15/summercms => ../../../summercms.go ``` The `replace` points at the same framework checkout as the application's `go.mod`, so the plugin builds against your local framework while you develop. ## Plugin registration In WinterCMS, `Plugin.php` describes the plugin and registers what it adds: ```php 'acme.blog::lang.plugin.name', 'description' => 'acme.blog::lang.plugin.description', 'author' => 'Acme', ]; } public function register() { $this->registerConsoleCommand('blog.publish', \Acme\Blog\Console\Publish::class); } public function registerPermissions() { return [ 'acme.blog.access_posts' => [ 'tab' => 'acme.blog::lang.plugin.name', 'label' => 'acme.blog::lang.permissions.access_posts', ], ]; } public function registerNavigation() { return [ 'blog' => [ 'label' => 'acme.blog::lang.plugin.name', 'url' => Backend::url('acme/blog/posts'), 'icon' => 'icon-pencil', 'permissions' => ['acme.blog.access_posts'], ], ]; } } ``` In SummerCMS the plugin is a Go type in the plugin's root package, `plugin.go`. `summer make:plugin acme.blog` writes it with every capability a new plugin usually needs: embedded config, language and mail files, models, migrations, commands, jobs and admin controllers. The plugin registers itself from `init`, and the application imports the package so that `init` runs. Here is the finished file; the sections below explain each part: ```go package blog import ( "context" "embed" "io/fs" "git.golem15.com/golem15/summercms/docs/examples/blog/console" "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/bonfire" "git.golem15.com/golem15/summercms/modules/lagoon" "git.golem15.com/golem15/summercms/modules/pact" "git.golem15.com/golem15/summercms/modules/party" "github.com/go-gormigrate/gormigrate/v2" "gorm.io/gorm" ) var ( _ pact.HasConfig = (*Plugin)(nil) _ pact.HasLang = (*Plugin)(nil) _ pact.HasMailTemplates = (*Plugin)(nil) _ pact.HasModels = (*Plugin)(nil) _ pact.HasMigrations = (*Plugin)(nil) _ pact.HasCommands = (*Plugin)(nil) _ pact.HasJobs = (*Plugin)(nil) _ pact.HasAdminControllers = (*Plugin)(nil) _ pact.AdminAssets = (*Plugin)(nil) _ pact.HasPermissions = (*Plugin)(nil) _ pact.HasNavigation = (*Plugin)(nil) ) //go:embed config var configFS embed.FS //go:embed lang var langFS embed.FS //go:embed views/mail var mailFS embed.FS //go:embed controllers/*/*.yaml models/*/*.yaml var adminFS embed.FS // Plugin is the acme.blog plugin, the Go form of Plugin.php. type Plugin struct { app *backpack.App } func (p *Plugin) ID() string { return "acme.blog" } func (p *Plugin) Requires() []string { return nil } func (p *Plugin) Register(*backpack.App) error { return nil } // Boot keeps the application, so route handlers and commands can reach its // services, such as the database, when they run. func (p *Plugin) Boot(app *backpack.App) error { p.app = app return nil } func (p *Plugin) ConfigFS() fs.FS { return configFS } func (p *Plugin) LangFS() fs.FS { return langFS } func (p *Plugin) MailTemplatesFS() fs.FS { return mailFS } func (p *Plugin) MailTemplates() []string { return nil } func (p *Plugin) MailLayouts() map[string]string { return nil } func (p *Plugin) Models() []any { return generatedModels() } func (p *Plugin) Migrations() []*gormigrate.Migration { return generatedMigrations() } func (p *Plugin) Jobs() []pact.Job { return generatedJobs() } func (p *Plugin) AdminControllers() []pact.AdminController { return generatedAdminControllers() } // Commands returns the generated commands plus blog:publish, which needs the // database and so is built here with the plugin's withDB. func (p *Plugin) Commands() []bonfire.Command { return append(generatedCommands(), console.PublishCommand(p.withDB)) } // AdminFS is the admin YAML the controllers read: controllers/posts and // models/posts. func (p *Plugin) AdminFS() fs.FS { return adminFS } // Permissions replaces registerPermissions(). func (p *Plugin) Permissions() []pact.Permission { return []pact.Permission{{ Code: "acme.blog.access_posts", Tab: "acme.blog::lang.plugin.name", Label: "acme.blog::lang.permissions.access_posts", }} } // Navigation replaces registerNavigation(). func (p *Plugin) Navigation() []pact.NavigationItem { return []pact.NavigationItem{{ Code: "blog", Label: "acme.blog::lang.plugin.name", Icon: "icon-pencil", Permissions: []string{"acme.blog.access_posts"}, Controller: "acme.blog.posts", }} } // withDB runs fn with the application's database: the one the serve command // published, or, when a console command runs, one opened from the config for // the duration of fn. func (p *Plugin) withDB(ctx context.Context, fn func(*gorm.DB) error) error { if gdb, ok := p.app.Lookup[*gorm.DB](); ok && gdb != nil { return fn(gdb) } sqlDB, gdb, err := lagoon.OpenFromApp(ctx, p.app) if err != nil { return err } defer sqlDB.Close() return fn(gdb) } func init() { party.Register(&Plugin{}) } ``` Each `var _ pact.HasX = (*Plugin)(nil)` line is a compile-time check that the plugin really implements a capability; the application finds the capabilities by type assertion. `Boot` keeps the application, because the route handler and the command reach the database through it. `Models`, `Migrations`, `Jobs` and `AdminControllers` return `generatedModels()` and the other accessors from `registry.gen.go`, which the `make:` commands rewrite each time they add a model, a migration, a command, a job or an admin controller. `AdminFS`, `Permissions` and `Navigation` were added by hand for the backend, and `Commands` for the console command. The plugin's defaults live in `config/config.yaml`, the equivalent of the plugin's `config/config.php`. They are merged under the plugin ID, so this value is read as `acme.blog.per_page`: ```yaml # Defaults for acme.blog, merged under the plugin ID: the application reads # this value as acme.blog.per_page and can override it in its own config. per_page: 15 ``` The language strings stay in YAML, one file per locale, and keep the WinterCMS `acme.blog::lang.` keys: ```yaml plugin: name: Blog description: A simple blog. permissions: access_posts: Manage blog posts posts: title: Posts post: Post title_field: Title slug: Slug body: Body published_at: Published ``` ## The Post model The WinterCMS model extends Eloquent and lists its mass-assignable columns in `$fillable` and its validation rules in `$rules`: ```php 'required|max:255', 'slug' => 'required|max:255|unique:acme_blog_posts', ]; } ``` `summer make:model acme.blog Post` writes `models/post.go` and a migration that creates the table. The model is a GORM struct; add its columns, keep the table name with `TableName`, and turn `$fillable` and `$rules` into methods: ```go // Post is a blog post, the Go form of the WinterCMS Acme\Blog\Models\Post // model. type Post struct { ID uint `gorm:"column:id;primaryKey"` Title string `gorm:"column:title"` Slug string `gorm:"column:slug"` Body string `gorm:"column:body"` PublishedAt *time.Time `gorm:"column:published_at"` CreatedAt time.Time `gorm:"column:created_at"` UpdatedAt time.Time `gorm:"column:updated_at"` } ``` ```go // Fillable is the Go form of $fillable: the only columns lagoon.Fill may // set from a request. func (Post) Fillable() []string { return []string{"title", "slug", "body"} } ``` ```go // Rules is the Go form of $rules. The admin API checks them with // lagoon.Validate on every save; unique ignores the post being updated. func (Post) Rules() map[string]string { return map[string]string{ "title": "required|max:255", "slug": "required|max:255|unique:acme_blog_posts", } } ``` `Post::make($input)` becomes a function that fills a new post through `lagoon.Fill` with that allow-list. Keys outside it, such as `id` or `published_at`, are dropped, so a request can never set them: ```go // NewPost is the Go form of Post::make($input): it copies only the fillable // keys of input onto a new post and drops the rest, such as id. func NewPost(input map[string]any) (*Post, error) { post := &Post{} if err := lagoon.Fill(post, post.Fillable(), input, true); err != nil { return nil, err } return post, nil } ``` The admin API uses the same `Fillable` list and `Rules` for every save. See [Models](/docs/database/models.md) for the other Eloquent conventions and [Casts and validation](/docs/database/casts-and-validation.md) for the rule strings. ## Migrations WinterCMS lists a plugin's updates in `updates/version.yaml`, each version naming the migration scripts it runs: ```yaml 1.0.1: - 'Create the posts table' - create_posts_table.php 1.0.2: - 'Add the publication date' - add_published_at.php ``` ```php increments('id'); $table->string('title'); $table->string('slug')->unique(); $table->text('body'); $table->timestamps(); }); } public function down() { Schema::dropIfExists('acme_blog_posts'); } } ``` In SummerCMS each migration is a gormigrate entry in `updates/`, in a file named after a UTC timestamp so the files sort in the order they run. There is no `version.yaml`: the plugin returns its migrations in order from `pact.HasMigrations`, and each plugin keeps its own history table. The migration `summer make:model` wrote creates the table with `id` and the timestamps; add the model's columns to it: ```go // CreatePosts returns the 20260101000000_create_acme_blog_posts gormigrate entry. func CreatePosts() *gormigrate.Migration { return &gormigrate.Migration{ ID: "20260101000000_create_acme_blog_posts", Migrate: func(tx *gorm.DB) error { return tx.Exec(`CREATE TABLE acme_blog_posts ( id BIGSERIAL PRIMARY KEY, title VARCHAR(255) NOT NULL, slug VARCHAR(255) NOT NULL UNIQUE, body TEXT NOT NULL DEFAULT '', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() )`).Error }, Rollback: func(tx *gorm.DB) error { return tx.Exec("DROP TABLE IF EXISTS acme_blog_posts").Error }, } } ``` `summer migrate` runs every plugin's pending migrations in plugin order. See [Migrations](/docs/database/migrations.md) for the history tables. ### Adding a column The second WinterCMS update adds the publication date: ```php Schema::table('acme_blog_posts', function ($table) { $table->timestamp('published_at')->nullable(); }); ``` `summer make:migration acme.blog AddPublishedAt` writes an empty migration in `updates/` and adds it to the plugin's list. Fill in `Migrate` and give `Rollback` a real inverse: ```go // AddPublishedAt returns the 20260101000100_add_published_at gormigrate entry. func AddPublishedAt() *gormigrate.Migration { return &gormigrate.Migration{ ID: "20260101000100_add_published_at", Migrate: func(tx *gorm.DB) error { return tx.Exec("ALTER TABLE acme_blog_posts ADD COLUMN published_at TIMESTAMPTZ NULL").Error }, Rollback: func(tx *gorm.DB) error { return tx.Exec("ALTER TABLE acme_blog_posts DROP COLUMN IF EXISTS published_at").Error }, } } ``` Add the `PublishedAt` field to the model (shown above), then apply the migration. While you develop, roll back the plugin's last migration, edit it and apply it again: ```sh summer migrate summer migrate:rollback --plugin acme.blog summer migrate ``` `migrate:rollback` undoes one migration of one plugin, here `published_at` only; the posts table stays. ## Routes A WinterCMS plugin declares its API endpoints in `routes.php`: ```php orderBy('published_at', 'desc') ->paginate(15); }); ``` The SummerCMS plugin implements `pact.HasRoutes` in `routes.go` and declares the route on a `pact.Router`. The handler reads the database the application published, counts and loads one page of published posts with bound parameters, and answers in the `{data, meta}` shape of Laravel's paginator through `lagoon.Paginate` and `wire.WriteJSON`: ```go package blog import ( "net/http" "strconv" "time" "git.golem15.com/golem15/summercms/docs/examples/blog/models" "git.golem15.com/golem15/summercms/modules/lagoon" "git.golem15.com/golem15/summercms/modules/pact" "git.golem15.com/golem15/summercms/modules/wire" "gorm.io/gorm" ) var _ pact.HasRoutes = (*Plugin)(nil) // Routes replaces routes.php. func (p *Plugin) Routes(r pact.Router) error { r.Get("/api/blog/posts", p.listPosts) return nil } // postJSON is the response shape of one post. It is built field by field, // so a column added to the model never leaks into the API. type postJSON struct { ID uint `json:"id"` Title string `json:"title"` Slug string `json:"slug"` Body string `json:"body"` PublishedAt wire.Time `json:"published_at"` } // listPosts answers GET /api/blog/posts?page=N&per_page=M with one page of // published posts, newest first, in the {data, meta} shape of Laravel's // paginator. Drafts, whose published_at is NULL, are never listed. func (p *Plugin) listPosts(w http.ResponseWriter, r *http.Request) { db, ok := p.app.Lookup[*gorm.DB]() if !ok { wire.WriteJSON(w, http.StatusServiceUnavailable, map[string]string{"message": "database unavailable"}) return } page := queryInt(r, "page", 1, 1, 10000) perPage := queryInt(r, "per_page", p.app.Config.Int("acme.blog.per_page"), 1, 100) q := db.WithContext(r.Context()).Model(&models.Post{}).Where("published_at IS NOT NULL") var total int64 if err := q.Count(&total).Error; err != nil { wire.WriteOpaque500(w) return } var posts []models.Post if err := q.Order("published_at DESC, id DESC").Offset((page - 1) * perPage).Limit(perPage).Find(&posts).Error; err != nil { wire.WriteOpaque500(w) return } rows := make([]postJSON, 0, len(posts)) for _, post := range posts { rows = append(rows, postJSON{ ID: post.ID, Title: post.Title, Slug: post.Slug, Body: post.Body, PublishedAt: wire.Time{Time: post.PublishedAt.UTC().Truncate(time.Second)}, }) } wire.WriteJSON(w, http.StatusOK, lagoon.Paginate(rows, page, perPage, total)) } // queryInt reads an integer query parameter, falling back to def when it is // missing or not a number, and clamps it to [lo, hi]. func queryInt(r *http.Request, name string, def, lo, hi int) int { n, err := strconv.Atoi(r.URL.Query().Get(name)) if err != nil { n = def } return min(max(n, lo), hi) } ``` The response is built from a separate `postJSON` type rather than the model, so a column added later does not appear in the API by accident, and `wire.Time` writes timestamps in the form Laravel does (`2026-01-03T10:00:00+00:00`). `page` and `per_page` are clamped, so a client cannot ask for the whole table at once. See [Routing](/docs/services/routing.md) for groups, middleware and authentication, and [Queries and pagination](/docs/database/queries-and-pagination.md) for sorting by a column the client names. ## Admin controller The WinterCMS backend controller implements the List and Form behaviours, requires a permission and names its YAML: ```php argument('slug'))->firstOrFail(); $post->published_at = $post->published_at ?: now(); $post->save(); $this->info('published ' . $post->slug); } } ``` `summer make:command acme.blog Publish` writes `console/publish.go` with a `bonfire.Command` named `blog:publish`. The command needs the database, which only the plugin can reach, so the finished function takes it as a parameter: ```go // Code generated by summer make. DO NOT EDIT. package console import ( "context" "fmt" "git.golem15.com/golem15/summercms/docs/examples/blog/models" "git.golem15.com/golem15/summercms/modules/bonfire" "gorm.io/gorm" ) // PublishCommand returns the blog:publish console command. withDB runs the // command's work with the application's database; the plugin supplies it. func PublishCommand(withDB func(ctx context.Context, fn func(*gorm.DB) error) error) bonfire.Command { return bonfire.Command{ Name: "blog:publish", Description: "Publish a blog post by its slug", Args: []bonfire.Arg{{Name: "slug", Description: "Slug of the post to publish", Required: true}}, Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error { slug, _ := in.Argument("slug") if slug == "" { return fmt.Errorf("blog:publish: a slug is required") } return withDB(ctx, func(db *gorm.DB) error { // A bound parameter, never the slug spliced into SQL. Publishing // twice keeps the first publication time. res := db.WithContext(ctx).Model(&models.Post{}). Where("slug = ?", slug). Update("published_at", gorm.Expr("COALESCE(published_at, NOW())")) if res.Error != nil { return res.Error } if res.RowsAffected == 0 { return fmt.Errorf("blog:publish: no post has the slug %q", slug) } out.Printf("published %s\n", slug) return nil }) }, } } ``` Because the function now takes a parameter, the generated accessor no longer lists it; the plugin adds it in `Commands` and passes its `withDB`, which uses the database the server published or, when the command runs from the console, opens one from the application config for the command's duration: ```go // Commands returns the generated commands plus blog:publish, which needs the // database and so is built here with the plugin's withDB. func (p *Plugin) Commands() []bonfire.Command { return append(generatedCommands(), console.PublishCommand(p.withDB)) } ``` ```go // withDB runs fn with the application's database: the one the serve command // published, or, when a console command runs, one opened from the config for // the duration of fn. func (p *Plugin) withDB(ctx context.Context, fn func(*gorm.DB) error) error { if gdb, ok := p.app.Lookup[*gorm.DB](); ok && gdb != nil { return fn(gdb) } sqlDB, gdb, err := lagoon.OpenFromApp(ctx, p.app) if err != nil { return err } defer sqlDB.Close() return fn(gdb) } ``` Build the application and run the command on its binary: ```sh summer build ./bin/acme blog:publish hello-world ``` See [Writing commands](/docs/console/writing-commands.md) for arguments, flags, prompts and output. ## What the scaffolder leaves to you The `make:` commands write files that compile, not a finished plugin. These are the steps this walkthrough had to do by hand, and the scaffolder behaviour behind them: - **The generated-code header stays.** Files the `make:` commands write start with `// Code generated by summer make. DO NOT EDIT.`, although you are meant to edit them. Keep the line: the commands rebuild `registry.gen.go` from the files that carry it, so a model, migration, command or admin controller whose header you delete disappears from the accessors the next time you run a `make:` command. Linters also treat these files as generated and skip them. - **Check the migration order.** A migration's file name and ID start with the second it was created in. Migrations with different names created in the same second get the same timestamp and run in file-name order, so `add_published_at` would run before `create_acme_blog_posts` and fail on the missing table. Look at `updates/` after scaffolding; if two files share a timestamp, rename the later one and its ID. The example uses fixed timestamps, `20260101000000` and `20260101000100`. - **The admin controller names the model after itself.** `make:admin-controller acme.blog Posts` returns `Posts` from `ModelName` and writes `modelClass: Posts`, and it puts `fields.yaml` and `columns.yaml` under `models/posts/`. Change `ModelName` and both `modelClass` values to the model, `Post`; the YAML can stay where it is, as in this example. - **The admin controller needs more than the scaffolder writes.** The generated controller implements only `pact.AdminController`. Add `NewRecord` (`pact.AdminRecordSource`) so the admin API has a model to query, and `RequiredPermissions` (`pact.AdminPermissioned`): without it, any signed-in administrator can open the controller. The model needs `Fillable` and `Rules` before the admin API can save it. The plugin needs `AdminFS` (`pact.AdminAssets`) to embed the YAML; without it the application refuses to start once the admin is enabled. - **A command that needs the application takes it as a parameter.** The function `make:command` writes takes no arguments, which is what the generated accessor looks for, so it cannot reach the database or the config. Give it the dependency as a parameter and return it from `Commands` yourself, as `blog:publish` does. ## Checklist What changed on the way from WinterCMS to SummerCMS: - `Plugin.php` became a `Plugin` type in `plugin.go`, registered from `init` with `party.Register`; each `register*` method became a capability interface such as `pact.HasPermissions` or `pact.HasNavigation`. - The plugin is a Go module the application imports; `summer plugin:add` and `summer build` replace dropping a directory into `plugins/`. - The Eloquent model became a GORM struct; `$fillable` and `$rules` became `Fillable` and `Rules` methods, and every mass assignment goes through `lagoon.Fill`. - `version.yaml` and the update scripts became timestamped gormigrate entries, each with a real `Rollback`. - `routes.php` became `Routes` on a `pact.Router`, with handlers that answer through a response type of their own and `lagoon.Paginate`. - The backend controller class became a `pact.AdminController` with a model, a permission and the same YAML; the generic admin API replaces the behaviours and their views. - The artisan command became a `bonfire.Command`, run with `./bin/acme blog:publish`. - Language strings and config defaults stay in YAML files embedded in the binary. # Architecture introduction Source: /docs/architecture/introduction.html How a SummerCMS application is built: one Go binary with compiled plugins, a headless JSON API, an embedded admin SPA and console commands. A SummerCMS application is one Go binary. The framework modules, the application's plugins, the embedded admin SPA and every console command are compiled into it. You deploy that file and its `config/` directory; nothing is installed or loaded at runtime. ## One binary, compiled plugins A plugin is a Go package that implements `party.Plugin` and registers itself from `init` with `party.Register`. The application lists the plugins it uses in its `summer.yaml` manifest. `summer build` reads that manifest, generates `plugins.gen.go` (a blank import for every plugin and the ordered `PluginIDs` list) and a `main.go`, then runs `go build`. Adding or removing a plugin is a rebuild, not a runtime switch. This replaces the WinterCMS plugin directory scan. The compiler checks every plugin against the interfaces it claims to implement, and a missing dependency fails the build or the boot, never a later request. ## Headless by design SummerCMS serves a JSON API and the admin SPA. It has no themes, CMS pages or frontend components: the public site is a separate application that calls the API and subscribes to realtime channels. The admin is a compiled Vue application that [boardwalk](/docs/api/boardwalk.md) serves from the binary, and it reads the admin API that [cabana](/docs/api/cabana.md) builds from each plugin's `fields.yaml` and `columns.yaml`. ## The framework modules Each framework module is one Go package under `modules/`, documented by its README and its API reference page. | Concern | Modules | |---------|---------| | Plugins and the container | [party](/docs/api/party.md), [pact](/docs/api/pact.md), [backpack](/docs/api/backpack.md), [festival](/docs/api/festival.md), [compass](/docs/api/compass.md) | | HTTP | [surf](/docs/api/surf.md), [towel](/docs/api/towel.md), [wire](/docs/api/wire.md), [bouncer](/docs/api/bouncer.md), [wristband](/docs/api/wristband.md), [fetchguard](/docs/api/fetchguard.md) | | Data | [lagoon](/docs/api/lagoon.md), [beachcomber](/docs/api/beachcomber.md) | | Admin | [cabana](/docs/api/cabana.md), [boardwalk](/docs/api/boardwalk.md) | | Services | [phrasebook](/docs/api/phrasebook.md), [postcard](/docs/api/postcard.md), [conga](/docs/api/conga.md), [lighthouse](/docs/api/lighthouse.md), [flare](/docs/api/flare.md) | | Console and tooling | [bonfire](/docs/api/bonfire.md), [tide](/docs/api/tide.md) | ## What runs where Two programs carry console commands: - The `summer` tool is the developer CLI. You install it once with `go install ./cmd/summer`. It builds and watches applications (`summer build`, `summer dev`), scaffolds plugins and their parts (`summer make:plugin`, `summer make:model` and the other `make:` commands), records and replays API parity fixtures and builds these docs. - The application binary, for example `bin/hello`, carries the runtime commands: `serve`, `migrate`, `route:list`, `queue:work`, `admin:create` and the commands its plugins add. It is what you run in production. Some `summer` commands, such as `summer migrate` and `summer serve`, run the matching command of the application binary in the current application directory, building it first when it is missing. The [Installation](/docs/setup/installation.md) guide walks through both programs. # Go modules and workspaces Source: /docs/architecture/go-modules-and-workspaces.html Require the framework module, develop plugins as local modules in a Go workspace, list them in summer.yaml and fork a plugin with a replace directive. WinterCMS uses Composer to pull in the framework and plugins. SummerCMS uses Go modules: the framework is one module, each plugin is its own module, and the application module requires them all. ## The framework module The framework is the module `git.golem15.com/golem15/summercms`. Every framework package is imported from `git.golem15.com/golem15/summercms/modules/`, for example `git.golem15.com/golem15/summercms/modules/party`. An application requires the framework in its `go.mod`. While you work against a local checkout of the framework, point the requirement at it with a `replace` directive. For an application module `acme` in a directory next to the framework checkout: ```text module git.golem15.com/acme/acme go 1.27.0 require git.golem15.com/golem15/summercms v0.0.0 replace git.golem15.com/golem15/summercms => ../summercms.go ``` `summer make:plugin` copies this framework `replace` into the new plugin's `go.mod`, rewritten relative to the plugin directory, so the plugin builds against the same checkout. ## The summer.yaml manifest The manifest in the application root names the application module, the binary `summer build` writes to `bin/`, and the plugins in activation order: ```yaml module: git.golem15.com/acme/acme binary: acme plugins: - id: acme.user module: git.golem15.com/acme/acme/plugins/user - id: acme.blog module: git.golem15.com/acme/acme/plugins/blog ``` `summer build` turns this list into `plugins.gen.go`. The order is the manifest order, adjusted so that each plugin comes after the plugins it requires. Plugins with no dependency between them keep the order you wrote. ## Local plugins in a workspace A plugin you develop inside the application lives in `plugins/` as its own module. `summer make:plugin acme.blog` creates it there, and `summer plugin:add plugins/blog` registers it: - it adds the plugin to `summer.yaml`; - it adds a `require` and a `replace` pointing at the local directory to the application's `go.mod`; - it adds the plugin directory to the nearest `go.work`, creating one in the application root when there is none. With a `go.work` in place, `go build`, `go test` and your editor see every local plugin module together. `summer build` uses the nearest `go.work` it finds; without one it builds in module mode. ```sh summer make:plugin acme.blog summer plugin:add plugins/blog summer build ``` ## Replacing and forking a plugin WinterCMS lets you replace a plugin by overriding its classes. In SummerCMS you fork the plugin's module and point the application at your fork with a `replace` directive. The plugin ID and the import path stay the same, so nothing else in the application changes: ```text replace git.golem15.com/acme/user => ../forks/user ``` Keep the fork's plugin ID unchanged when it must stand in for the original, since other plugins require it by ID. If you want both to exist side by side, give the fork a new module path and a new ID and list it in `summer.yaml` instead. To extend a plugin without forking it, see [Extending plugins](/docs/plugins/extending.md). # Application lifecycle Source: /docs/architecture/application-lifecycle.html What happens when an application binary starts: configuration, the backpack container, plugin ordering, Register and Boot, and database-dependent boot work. Every run of an application binary, whether it serves HTTP or runs a single console command, goes through the same start-up. `summer build` generates the `main.go` that performs it, so you never write it by hand. ## Start-up sequence The generated `main` does the following, in order: 1. Loads configuration with `compass.Load` from the `config/` directory, applying the environment directory and `SUMMER_` variables. 2. Creates the application container with `backpack.New`. 3. Activates the plugins listed in `summer.yaml` with `party.Activate`. 4. Collects the console commands: the framework's runtime commands (from `lagoon.RuntimeCommands`, `conga.RuntimeCommands`, `surf.ServeCommand`, `surf.RouteListCommand` and `cabana.RuntimeCommands`), then the commands of every plugin that implements `pact.HasCommands`. 5. Publishes the command set as a `bonfire.Catalog`, so the scheduler can run commands in-process. 6. Runs the command named on the command line. ## Plugin ordering `party.Activate` selects the plugins by ID and orders them so that every plugin comes after the plugins its `party.Plugin.Requires` lists. It fails before any plugin code runs when an ID is empty, duplicated or not compiled in, when a required plugin is missing, or when the requirements form a cycle. ## Register, then Boot Activation runs in phases, and each phase finishes for every plugin before the next begins: 1. `backpack.App.SetPlugins` records the complete plugin set, so `backpack.App.HasPlugin` answers correctly from the first Register onwards. 2. The embedded defaults of every plugin that implements `pact.HasConfig` are merged into the configuration under the plugin ID. 3. `party.Plugin.Register` runs for every plugin. Publish services here; do not use other plugins' services yet. 4. The framework publishes the translator and the mailer, and registers each plugin's translations and mail templates. 5. `party.Plugin.Boot` runs for every plugin. Look up services, register event listeners and extend other plugins here. This is the WinterCMS `register` and `boot` split: when any Boot runs, every plugin has already registered. ## The container `backpack.App` is the application container that Register and Boot receive. It holds the configuration in `backpack.App.Config`, the event bus in `backpack.App.Events` and a typed service registry. Nothing in it is process-global, so two applications in one test do not share state. Services are keyed by their Go type. A plugin publishes a value with `backpack.App.Publish` and another plugin reads it with `backpack.App.Lookup` and the same type argument. Publish under an interface type when consumers should not depend on your implementation: ```go app := backpack.New(&compass.Config{}) app.SetPlugins([]string{"acme.greeter", "acme.blog"}) // acme.greeter, in its Register step: publish under the interface type. var greeter Greeter = englishGreeter{} if err := app.Publish(greeter); err != nil { fmt.Println(err) return } // acme.blog, in its Boot step: look the service up by the same type. if app.HasPlugin("acme.greeter") { if found, ok := app.Lookup[Greeter](); ok { fmt.Println(found.Greet("blog")) } } // A second Publish under the same type is refused. fmt.Println(app.Publish(greeter) != nil) // Output: // Hello, blog // true ``` ## Capability interfaces Beyond the four `party.Plugin` methods, a plugin declares what it contributes by implementing interfaces from [pact](/docs/api/pact.md). The framework package that owns a capability finds it with a type assertion: surf asks for `pact.HasRoutes` and `pact.HasMiddleware`, lagoon for `pact.HasMigrations`, cabana for `pact.HasAdminControllers`, conga for `pact.HasJobs` and `pact.HasSchedule`. A plugin that does not implement an interface simply does not take part in that capability. ## Database-dependent boot work Boot runs before any command opens the database: `migrate` and `serve` open it after activation, and commands such as `key:generate` never open it. Code that needs the database handle during boot, such as registering GORM callbacks, therefore goes through `lagoon.OnDatabase`. It runs the function immediately when the database is already published, and otherwise queues it until `lagoon.Publish` makes the shared `*sql.DB` and `*gorm.DB` handles available. An error from a queued function is returned by `lagoon.Publish`, so the command that opened the database fails instead of running with a half-registered plugin. [Extending plugins](/docs/plugins/extending.md) shows where GORM callbacks fit. # Request lifecycle Source: /docs/architecture/request-lifecycle.html How an HTTP request reaches a plugin handler: route collection, named middleware, constraints, body limits, CORS, recovery and request context values. The `serve` command builds one `http.Handler` from every plugin's route declarations and serves it with the Go standard library. This page follows a request from the socket to your handler and back. ## Building the router When `serve` starts, `surf.BuildRouter` collects the routes: 1. It registers the built-in middleware (`throttle`, `body.limit`, `locale.from-principal` and, when the admin is enabled, `backend`). 2. It registers the named middleware of every plugin that implements `pact.HasMiddleware`, `pact.HasMiddlewareFactories` or `pact.HasHouseMiddleware`, and the rate-limit buckets of every `surf.BucketProvider`. 3. It calls `pact.HasRoutes.Routes` on every plugin in activation order, passing a `pact.Router` that records groups, routes, middleware names and constraints. 4. It mounts the cabana admin API and checks every route. `surf.Assemble` then compiles the routes onto a standard library `http.ServeMux`. A duplicate route, an unknown middleware name or a malformed `throttle` parameter fails here, at boot, and `serve` exits with the error instead of serving a broken router. `./bin/acme route:list` builds the same router without opening the database and prints the route table. ## Declaring routes A plugin declares routes the way a WinterCMS `routes.php` file does, with groups that share a prefix and middleware: ```php Route::group(['prefix' => 'api/blog', 'middleware' => ['auth']], function () { Route::get('posts/{id}', 'Acme\Blog\Http\Posts@show')->where('id', '[0-9]+'); }); ``` In Go the same declaration is a `pact.HasRoutes` method. Paths use Go `http.ServeMux` patterns, `pact.Router.Where` and `pact.Router.WhereIn` constrain the last declared route, and middleware names are strings that must be registered by the time the router is built. The [surf](/docs/api/surf.md) reference has the full route builder with rate-limit buckets and per-route body limits. ## The wrapping order surf wraps every non-raw route in the same layers. From the outside in: 1. CORS, only for the paths configured under `http.cors.paths`, including preflight requests. 2. Panic recovery. A panic becomes an opaque JSON 500 from [wire](/docs/api/wire.md), and because the response is buffered until the handler returns, the client never receives half a body. 3. The request locale, taken from the `Accept-Language` header and stored in the context. 4. The body limit: `http.body_limits.default_bytes`, or the route's own `body.limit:`. 5. The route's middleware, in the order you listed them: group middleware first, then the route's own. 6. The path constraints. A request whose parameter fails `pact.Router.Where` or `pact.Router.WhereIn` gets a 404 before your handler runs. 7. Your handler. Raw groups, declared with `pact.Router.GroupRaw`, are for webhooks and file streams. They skip the default body limit, refuse house middleware, and a panic in them returns a bare 500. ## Request context values WinterCMS reads the current locale and user through facades. SummerCMS carries them on the request's `context.Context`. surf stores the locale with `towel.WithLocale`, the authentication middleware stores the signed-in principal, and your own middleware can add the organization or collection with `towel.WithOrganization` and `towel.WithCollection`. Any code that receives the context reads them back without a global lookup: ```go // A middleware stores the organization once for the whole request. withAcme := func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ctx := towel.WithOrganization(r.Context(), "acme") next.ServeHTTP(w, r.WithContext(ctx)) }) } // The handler reads the values back from its request context. listPosts := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { org, _ := towel.Organization(r.Context()) locale, ok := towel.Locale(r.Context()) if !ok { locale = "en" } fmt.Fprintf(w, "posts for %s in %s", org, locale) }) // surf sets the locale from Accept-Language; here the test sets it. req := httptest.NewRequest(http.MethodGet, "/api/blog/posts", nil) req = req.WithContext(towel.WithLocale(req.Context(), "pl")) rec := httptest.NewRecorder() withAcme(listPosts).ServeHTTP(rec, req) fmt.Println(rec.Body.String()) // Output: posts for acme in pl ``` ## Writing responses Handlers write JSON with `wire.WriteJSON`, which keeps bodies byte-compatible with a Laravel backend: HTML characters are not escaped and there is no trailing newline. `wire.Slice` turns a nil list into `[]`, `wire.Time` marshals timestamps in Carbon's `+00:00` form and `wire.TriBool` models a nullable boolean. Errors that should look like your API's error envelope go through the house middleware a plugin registers with `pact.HasHouseMiddleware`. # Performance and scaling Source: /docs/architecture/performance-and-scaling.html Why a SummerCMS binary serves requests faster and with less memory than WinterCMS on PHP-FPM, where it does not, and how to scale and measure it. WinterCMS runs under PHP-FPM, which boots the application again for every request. A SummerCMS application is one Go process that boots once and then serves every request from memory. This page explains where the speed and memory savings come from, where a port will not get faster, and how to scale and measure an application. The figures on this page are typical for PHP-FPM with Laravel compared with Go services. SummerCMS has no published benchmarks yet, so treat them as expectations, not promises, and use [Measuring it yourself](#measuring-it-yourself) to get real numbers for your application. ## Boot once, not per request PHP-FPM is shared-nothing: every request starts from an empty process state and re-runs the Laravel and Winter bootstrap. Service providers load, plugins register, configuration is read, routes are collected and the YAML caches are looked up. Even with OPcache this typically costs about 15-60 ms before your controller runs. SummerCMS does that work once, at start-up. [party](/docs/api/party.md) runs every plugin's Register and Boot (`party.Activate`), [cabana](/docs/api/cabana.md) compiles each plugin's `fields.yaml` and `columns.yaml` into a `cabana.CompiledController`, and [surf](/docs/api/surf.md) assembles every route into one standard library `http.ServeMux` (`surf.Assemble`). A request then only matches a route and runs its middleware chain, which costs microseconds. [Application lifecycle](/docs/architecture/application-lifecycle.md#start-up-sequence) lists the start-up steps and [Request lifecycle](/docs/architecture/request-lifecycle.md) follows a request through the router. ## Memory An FPM worker typically holds 30-80 MB, and FPM needs one worker for every request it serves at the same time. Twenty workers therefore take roughly 1-1.5 GB before the database or the cache is counted. One SummerCMS process typically sits at 50-150 MB while it serves thousands of concurrent requests, because every request shares the same compiled routes, admin schemas and configuration. ## Slow I/O and concurrency A PHP request that waits on an external API, an SMTP server, the search engine or the realtime server holds its FPM worker for the whole wait. When every worker is busy (`pm.max_children` is reached), new requests queue in front of FPM and p99 latency climbs sharply, even though the server's CPUs are idle. In Go each request runs on its own goroutine, which costs a few KB of memory. A request that waits on the network parks its goroutine, and the runtime keeps serving other requests on the same threads, so slow upstreams do not block unrelated routes. ## Database connections PHP-FPM usually opens one Postgres connection per worker, so the connection count grows with `pm.max_children` and with every server you add. SummerCMS opens one `database/sql` pool per process. [lagoon](/docs/api/lagoon.md) publishes it with `lagoon.Publish`, GORM queries run on it, and [conga](/docs/api/conga.md) runs River jobs on the same pool. Connections are shared between requests and jobs instead of being held by idle workers. When the job worker runs, it adds one dedicated connection that listens for new jobs; [Running workers](/docs/services/jobs.md#running-workers) explains it and what it needs from PgBouncer. ## Model hydration Eloquent hydrates every row into a model object: it builds attribute arrays and runs magic accessors, mutators and casts for each model. GORM scans rows into plain Go structs through reflection, with no per-attribute magic, so each row costs less to load. The difference grows with the number of rows a route returns. ## What to expect > [!NOTE] > The ranges below are typical for PHP-FPM with Laravel compared with Go services. They are not measurements of SummerCMS, which has no benchmarks yet. [Measuring it yourself](#measuring-it-yourself) shows how to get real numbers for your application. | Endpoint shape | Examples | Typical gain over PHP-FPM | |----------------|----------|---------------------------| | Trivial or cached responses | Health checks, settings or lookup endpoints | 10-30x throughput per core, with sub-millisecond latency in Go | | Typical CRUD | Paginated lists, a record with a relation or two, validated writes | 3-10x | | Database-heavy | Large joins, aggregates, full-text queries | 1.2-2x, because time spent in Postgres dominates | The less time a route spends waiting on Postgres, the more of the PHP bootstrap and hydration cost the port removes. ## Where you will not win Some costs come over with the port unchanged, and a few framework behaviours need attention once you run more than one instance: - A line-by-line port keeps the PHP queries, so slow queries and N+1 patterns come over unchanged. Fix them, for example with GORM's `Preload`, once the port passes parity. - [wire](/docs/api/wire.md) keeps response bodies byte-compatible with the PHP backend, so payload sizes and the client's parsing work do not change. - Revoked tokens must be visible to every instance. Use `bouncer.NewPostgresBlacklist` (a `bouncer.PostgresBlacklist`) rather than the in-memory blacklist, so a logout on one replica applies on all of them; see [Refreshing and revoking](/docs/services/authentication.md#refreshing-and-revoking). - `serve` runs the conga job worker in the same process as the HTTP server by default. Under load, set `queue.work_in_serve` to `false` and run `./bin/acme queue:work` as separate processes, so long jobs do not compete with requests for CPU; see [Running workers](/docs/services/jobs.md#running-workers). - Thumbnails are generated in pure Go (`attach.File.Thumb`), which can be slower than GD or Imagick for large images. - Garbage collection pauses are sub-millisecond and do not matter at this scale. > [!WARNING] > Rate limits are counted per process. The throttle middleware that `serve` builds keeps its counters in memory (`surf.MemoryStore`, behind the `surf.Store` interface), and `serve` offers no way to supply a different store yet. With N replicas the effective limit is N times the configured one. Until a shared store exists, divide the per-route limits by the replica count, or enforce the limit at the load balancer. [Rate limiting](/docs/services/rate-limiting.md#named-buckets) describes the buckets. ## Scaling and operations The application is one stateless binary. It starts in milliseconds, ships in a small container image and needs no OPcache warm-up after a deploy. - Vertical scaling is more cores. The Go runtime uses them all, so there is no worker count to tune. - Horizontal scaling is N replicas behind a load balancer that share Postgres. Uploads must be shared too: point `storage.uploads.bucket_url` at a `file://` directory on storage that every replica mounts (see [Storage](/docs/services/storage.md#bucket-urls)). Apply the blacklist and rate-limit notes above before you add the second replica. - Centrifugo scales on its own. [lighthouse](/docs/api/lighthouse.md) only publishes to it and issues connection tokens, so adding application replicas does not change the realtime server; see [The Centrifugo driver](/docs/services/realtime.md#the-centrifugo-driver). A rollout runs the migrations once, then starts each replica behind the reverse proxy: ```sh ./bin/acme migrate ./bin/acme serve --addr 127.0.0.1:8080 ``` ## Measuring it yourself Compare the two backends on the routes your frontend actually calls. First record fixtures from the PHP backend with [tide](/docs/api/tide.md) and replay them against the port, so you know both return the same responses: ```sh summer parity:record --spec testdata/parity/posts.spec.yaml --target http://127.0.0.1:8000 --output testdata/parity/posts.yaml --vars /tmp/parity/vars.yaml summer parity:replay --fixtures testdata/parity --target http://127.0.0.1:8080 --vars /tmp/parity/vars.yaml ``` [The parity commands](/docs/services/parity-testing.md#the-parity-commands) explains the flags and the variables file. Once replay passes, drive the same routes under load against both backends with a load tool such as vegeta or k6. Run both on identical hardware against the same Postgres instance, with OPcache enabled and warm on the PHP side. For each route, compare p50 and p99 latency, requests per second and the resident memory (RSS) of the PHP-FPM pool and of the Go process. # Plugin registration Source: /docs/plugins/registration.html Declare a plugin: its ID, the party.Plugin lifecycle, the pact capability interfaces it opts into, its embedded files and the scaffolded layout. Plugins are the foundation of every SummerCMS application. A plugin adds models, routes, admin screens, console commands, jobs and translations, and it can extend other plugins. This page covers how a plugin tells the framework what it contributes. ## Plugin identifiers Every plugin has an ID in `vendor.plugin` form: two lower-case parts, each starting with a letter and containing only letters and digits, such as `acme.blog`. The ID is how the manifest lists the plugin, how other plugins require it and how its configuration is namespaced (`acme.blog.posts_per_page`). WinterCMS writes the same identifier as `Acme.Blog`; in SummerCMS it is always lower case. The scaffolder derives the package and directory name from the second part, so `summer make:plugin acme.blog` creates `plugins/blog` with `package blog`. ## The plugin type A plugin is a Go type that implements `party.Plugin`. Its package registers it from `init` with `party.Register`, so importing the package is enough to make the plugin available; the generated `plugins.gen.go` does that import for every plugin in `summer.yaml`. | Method | Purpose | |--------|---------| | `party.Plugin.ID` | Returns the plugin ID. | | `party.Plugin.Requires` | Lists the IDs of plugins that must register and boot before this one, like `$require` in WinterCMS. | | `party.Plugin.Register` | Runs before any plugin boots. Publish services on the container here. | | `party.Plugin.Boot` | Runs after every plugin registered. Listen to events and use other plugins' services here. | Here is a complete plugin that also declares a backend permission: ```go package party_test import ( "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/pact" ) // BlogPlugin is the acme.blog plugin: the Go form of a WinterCMS Plugin.php. // A real plugin package also registers it from init with // party.Register(&BlogPlugin{}). type BlogPlugin struct{} // The optional capabilities the plugin opts into, checked at compile time. var _ pact.HasPermissions = (*BlogPlugin)(nil) // ID is the plugin identifier in vendor.plugin form. func (p *BlogPlugin) ID() string { return "acme.blog" } // Requires lists the plugins that must register and boot first ($require). func (p *BlogPlugin) Requires() []string { return []string{"acme.user"} } // Register runs before any plugin boots: publish services here. func (p *BlogPlugin) Register(app *backpack.App) error { return nil } // Boot runs after every plugin registered: listen to events and look up // services other plugins published. func (p *BlogPlugin) Boot(app *backpack.App) error { return nil } // Permissions replaces registerPermissions(). func (p *BlogPlugin) Permissions() []pact.Permission { return []pact.Permission{ {Code: "acme.blog.access_posts", Tab: "Blog", Label: "Manage posts"}, } } ``` The `var _ pact.HasPermissions = (*BlogPlugin)(nil)` line is a compile-time check: if a method is missing or has the wrong signature, the build fails instead of the capability being silently ignored. Add one such line for every capability your plugin implements. ## Capability interfaces A WinterCMS plugin overrides `register*` methods of `PluginBase`. A SummerCMS plugin implements small interfaces from [pact](/docs/api/pact.md) instead, and the framework discovers each one with a type assertion. | Interface | Contributes | |-----------|-------------| | `pact.HasRoutes` | HTTP routes, declared on a `pact.Router`. | | `pact.HasMiddleware`, `pact.HasMiddlewareFactories` | Named and parameterized route middleware. | | `pact.HasConfig` | Default configuration, merged under the plugin ID. | | `pact.HasMigrations` | An ordered set of database migrations. | | `pact.HasCommands` | Console commands for the application binary. | | `pact.HasJobs` | Background jobs. | | `pact.HasSchedule` | Console commands that run on a schedule; see [Scheduling](/docs/plugins/scheduling.md). | | `pact.HasLang`, `pact.HasLangOverrides` | Translations, and overrides of other namespaces. | | `pact.HasMailTemplates` | Mail templates and layouts. | | `pact.HasPermissions`, `pact.HasNavigation`, `pact.HasSettings` | Backend permissions, navigation and settings screens. | | `pact.HasAdminControllers` | Admin controllers built from `fields.yaml` and `columns.yaml`. | | `pact.HasModels` | The plugin's GORM models. No framework package reads it yet. | ## Embedded files Configuration defaults, translations and mail templates ship inside the binary through Go's `embed` package. The plugin returns an `fs.FS` for each: - `pact.HasConfig.ConfigFS` returns a tree with `config/config.yaml`. Its keys become `.`, and any other `config/.yaml` becomes `..`. The application's own `config/` directory and `SUMMER_` variables override them. - `pact.HasLang.LangFS` returns `lang//.yaml` files. - `pact.HasMailTemplates.MailTemplatesFS` returns the `views/mail` templates, and `pact.HasMailTemplates.MailTemplates` lists their names. ## The scaffolded layout `summer make:plugin acme.blog` writes a plugin that compiles and follows the WinterCMS directory layout, with each directory as a Go subpackage: ```text plugins/blog/ ├── go.mod the plugin module, requiring the framework ├── plugin.go the Plugin type, its capabilities and init registration ├── routes.go the Routes method ├── registry.gen.go generated lists of models, migrations, commands, jobs and admin controllers ├── classes/ services and hooks ├── config/config.yaml default configuration ├── console/ console commands ├── controllers/ HTTP handlers and admin controllers ├── jobs/ background jobs ├── lang/en/lang.yaml translations ├── middleware/ named middleware ├── models/ GORM models ├── updates/ migrations └── views/mail/ mail templates ``` The `make:` commands, such as `summer make:model acme.blog Post`, add files to these directories and regenerate `registry.gen.go`, so you do not edit that file by hand. The capability methods in `plugin.go` return the generated lists, so a new model, migration, command, job or admin controller is picked up without editing the plugin type. # Task scheduling Source: /docs/plugins/scheduling.html Run a plugin's console commands on a schedule with pact.HasSchedule, and run the scheduler in the worker, as its own process or from system cron. WinterCMS plugins schedule work in `registerSchedule`. A SummerCMS plugin declares the same thing by implementing `pact.HasSchedule`: it returns a list of its registered console commands, each with the arguments to pass and how often to run it. Only these compiled entries ever run; there is no way to schedule an arbitrary command at runtime. ## Defining schedules Each entry is a `pact.ScheduledCommand`: the command name in `namespace:verb` form, its arguments and a `pact.Cadence`. The command must be registered by some plugin through `pact.HasCommands`. ```go // Schedule runs three of the plugin's registered console commands. func (p *BlogPlugin) Schedule() []pact.ScheduledCommand { return []pact.ScheduledCommand{ {Command: "blog:prune-drafts", Cadence: pact.Daily()}, {Command: "blog:send-digest", Cadence: pact.DailyAt(7, 30)}, {Command: "blog:sync-feed", Args: []string{"--quiet"}, Cadence: pact.Every(15 * time.Minute)}, } } ``` Build a cadence with one of three functions: | Function | Runs | Laravel equivalent | |----------|------|--------------------| | `pact.Daily` | Every day at 00:00. | `->daily()` | | `pact.DailyAt` | Every day at the given hour and minute. | `->dailyAt('07:30')` | | `pact.Every` | At every multiple of the interval since midnight, so `pact.Every(15 * time.Minute)` runs at :00, :15, :30 and :45. | `->everyFifteenMinutes()` | Times are wall-clock times in the `app.timezone` location (UTC when it is not set). On a daylight saving day a daily entry keeps its wall-clock time. The scheduler checks every entry when a worker starts, and the start fails with an error naming the plugin ID and the entry index when: - the command name is empty or the cadence is the zero `pact.Cadence`; - a daily hour or minute is out of range; - an `pact.Every` interval is shorter than one second or does not divide 24 hours evenly. A command that no plugin registers does not fail the start: each run logs a warning and is skipped. You can inspect a cadence with `pact.Cadence.At` (the hour and minute of a daily cadence) and `pact.Cadence.Interval` (24 hours for a daily cadence). This example prints the entries above: ```go var plugin pact.HasSchedule = &BlogPlugin{} for _, entry := range plugin.Schedule() { if hour, minute, daily := entry.Cadence.At(); daily { fmt.Printf("%s %v: daily at %02d:%02d\n", entry.Command, entry.Args, hour, minute) continue } fmt.Printf("%s %v: every %s\n", entry.Command, entry.Args, entry.Cadence.Interval()) } // Output: // blog:prune-drafts []: daily at 00:00 // blog:send-digest []: daily at 07:30 // blog:sync-feed [--quiet]: every 15m0s ``` ## How schedules run The schedule runs inside the background job worker from [conga](/docs/api/conga.md). Every worker turns each entry into a periodic job with the ID `[]:`, for example `acme.blog[0]:blog:prune-drafts`. When several instances of the application run, one worker is elected leader and only the leader enqueues due runs, so each period runs once across all instances, even when the leader changes mid-period. Each run is a job on the `scheduled` queue with a single attempt: an interrupted run is not retried, and the next period runs normally. The worker calls the command in-process and logs its output line by line. The worker runs in `serve` by default. When you run workers separately (`queue.work_in_serve` set to `false`), `./bin/acme queue:work` carries the schedule too. ## Running the scheduler on its own To run only the scheduler in its own process, use `schedule:run`. It starts a worker on the `scheduled` queue and runs until it receives SIGINT or SIGTERM: ```sh ./bin/acme schedule:run ``` If you prefer system cron, as in Laravel, use `schedule:run --once` every minute. It runs, without the job queue, every entry that is due in the current minute and exits: ```sh * * * * * cd /srv/acme && ./bin/acme schedule:run --once ``` `schedule:run --once` has no overlap lock: two runs in the same minute run the due entries twice, as Laravel does. From the application directory during development, `summer schedule:run --once` runs the same command through the built binary. # Extending plugins Source: /docs/plugins/extending.html Extend other plugins through typed events, published services, optional dependencies and GORM callbacks, and replace a plugin by forking its module. Plugins in WinterCMS extend each other by listening to events and by calling `extend` on another plugin's classes at runtime. Go has no runtime class extension, so SummerCMS gives you four explicit mechanisms: events, published services, optional dependencies and database callbacks. When none of them fits, you fork the plugin. ## Events The event bus from [festival](/docs/api/festival.md) is the Go form of `Event::listen` and `Event::fire`. Each application has one bus, `backpack.App.Events`. A plugin that wants to be extensible defines an event type and fires it; other plugins listen for that type from their Boot step. Events are routed by Go type, not by a string name, so a listener for `PostPublished` receives exactly that type and a payload mismatch does not compile: ```go bus := festival.New() // in a plugin, use app.Events // acme.search and acme.notify extend acme.blog from their Boot steps. bus.Listen("acme.search", func(ctx context.Context, e PostPublished) error { fmt.Println("index", e.Title) return nil }) bus.ListenPriority("acme.notify", 10, func(ctx context.Context, e PostPublished) error { fmt.Println("notify subscribers of", e.Title) return nil }) // acme.blog fires the event; higher priorities run first. if err := bus.Fire(context.Background(), PostPublished{Title: "Hello"}); err != nil { fmt.Println(err) } // Output: // notify subscribers of Hello // index Hello ``` Each listener names the plugin that owns it, so an error or a recovered panic in a listener reports which plugin failed. The bus has three dispatch modes: - `festival.Bus.Fire` runs every listener and returns their joined errors. - `festival.Bus.Collect` runs every listener and merges the payload each one adds, for events that gather contributions such as extra fields or menu items. The event implements `festival.Collectable`. - `festival.Bus.UntilHandled` stops at the first listener that handles the event, the WinterCMS halting fire. The event implements `festival.Handleable`. `festival.Bus.ListenPriority` sets a priority: higher priorities run first, and equal priorities run in registration order. ## Services A plugin that offers functionality to others publishes it on the container during Register with `backpack.App.Publish`, preferably under an interface type. Other plugins read it during Boot with `backpack.App.Lookup`. Because both sides use the same type, the consumer only imports the package that declares the interface, not the provider's internals. See [Application lifecycle](/docs/architecture/application-lifecycle.md) for a complete example. ## Optional dependencies A required dependency goes in `party.Plugin.Requires`, and activation fails when it is missing. For an integration that should work only when another plugin happens to be installed, check for it instead: - `backpack.App.HasPlugin` reports whether a plugin ID is part of this build. It answers correctly during Register, before that plugin has booted. - `pact.OptionalMessage` is a small service an optional plugin can publish so others integrate with it without importing its package. This replaces `PluginManager::exists` checks in WinterCMS. ## Model hooks and GORM callbacks A model reacts to its own lifecycle with GORM hook methods such as `BeforeSave`; [lagoon](/docs/api/lagoon.md) names them as interfaces (`lagoon.HasBeforeSave`, `lagoon.HasBeforeDelete` and the rest) so you can assert them at compile time. To react to another plugin's models, the equivalent of `Post::extend` with model events, register a GORM callback on the shared database handle. Boot runs before the database is open, so register it through `lagoon.OnDatabase`, which calls your function with the shared `*gorm.DB` once it is available. For work that must wait until the transaction commits, such as sending mail or publishing a realtime event, use `lagoon.AfterCommit`. ## Replacing a plugin When an extension point is missing, fork the plugin's module and point the application at your copy with a `replace` directive in its `go.mod`, keeping the plugin ID. The rest of the application keeps importing and requiring the original path. [Go modules and workspaces](/docs/architecture/go-modules-and-workspaces.md) shows the directive. Prefer adding an event or a published service to the original plugin over a long-lived fork: a fork has to be kept in step with every change upstream. # Testing plugins Source: /docs/plugins/testing.html Test plugins with go test, run database tests against real PostgreSQL containers, replay API parity fixtures and keep documentation examples running. SummerCMS uses the standard Go test tooling. There is no separate test runner and no PHPUnit bootstrap: a plugin's tests are `_test.go` files next to its code, and `go test` runs them. ## Running tests Run every test in the module from its root: ```sh go vet ./... go test ./... ``` Tests that need Docker, such as database tests, skip themselves in short mode. Use it for a fast loop: ```sh go test -short ./... ``` In an application with local plugins in a `go.work` workspace, run the tests of one plugin by its directory, for example `go test ./plugins/blog/...`. ## Unit tests without a database Most plugin code runs without a database. Build a container with `backpack.New`, call your plugin's Register and Boot, and assert on what it published. Drive HTTP handlers with `net/http/httptest`: `surf.Assemble` builds the same handler `serve` uses, so a test can send requests to your routes without listening on a port. Call console commands in-process with `bonfire.Call`. ## Database tests SummerCMS supports PostgreSQL only, so database tests run against real PostgreSQL rather than an SQLite stand-in. The framework's own tests start a `postgres:16-alpine` container through testcontainers-go and create a fresh database per test. Follow the same pattern in plugin tests: - skip the test when `testing.Short` reports true; - migrate a fresh database per test with `lagoon.Migrate`, so tests do not depend on each other. Docker must be running for these tests. ## API parity tests When you port an existing backend, its real responses are the acceptance test. [tide](/docs/api/tide.md) records request and response fixtures from the reference backend and replays them against your port, reporting differences after masking IDs and timestamps. The `summer parity:record`, `summer parity:replay`, `summer parity:proxy` and `summer parity:broadcasts` commands wrap it. ## Examples in the documentation Every Go code block in these docs is a copy of an `Example` function or a marked region of a test that `go test ./...` runs. If you change a framework API and forget an example, `go test` fails. Write your plugin's examples the same way: an `Example` function with an `// Output:` comment is compiled, run and compared by `go test`, so it cannot go stale. # Admin controllers Source: /docs/backend/admin-controllers.html Declare admin controllers with pact.AdminController and WinterCMS-shaped YAML, and let the generic JSON admin API list, show, create, update and delete records. A WinterCMS backend controller extends `Backend\Classes\Controller`, implements the List, Form and Relation behaviours, and describes its screens in `config_list.yaml`, `config_form.yaml` and the model's `columns.yaml` and `fields.yaml`. SummerCMS keeps the YAML, and drops the controller class: [cabana](/docs/api/cabana.md) compiles the YAML at boot and serves one generic JSON admin API for every controller, and the admin SPA renders the screens from the compiled schemas. ## Declaring a controller A controller is a small Go type that implements `pact.AdminController`: its ID, the model name that `modelClass` in the YAML must match, and the directory that holds its YAML. It usually also implements `pact.AdminRecordSource`, which returns the GORM model to query, and `pact.AdminPermissioned`, the permissions an administrator needs. The plugin returns its controllers from `pact.HasAdminControllers` and its embedded YAML tree from `pact.AdminAssets`: ```go package cabana_test import ( "io/fs" "os" "time" "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/pact" ) // Post is the model behind the acme.blog posts controller. type Post struct { ID uint `gorm:"column:id;primaryKey"` Title string `gorm:"column:title"` Slug string `gorm:"column:slug"` Status string `gorm:"column:status"` Published bool `gorm:"column:published"` PublishedAt *time.Time `gorm:"column:published_at"` Content string `gorm:"column:content"` } func (Post) TableName() string { return "acme_blog_posts" } // Fillable lists the columns the admin form may write. func (Post) Fillable() []string { return []string{"title", "slug", "status", "published", "content"} } // PostsController is the admin controller for posts: the Go form of a // WinterCMS controller with the List and Form behaviours. type PostsController struct{} var ( _ pact.AdminController = PostsController{} _ pact.AdminRecordSource = PostsController{} _ pact.AdminPermissioned = PostsController{} _ pact.HasAdminControllers = (*BlogPlugin)(nil) ) // ID is the controller ID; its admin API path is /acme/blog/posts. func (PostsController) ID() string { return "acme.blog.posts" } // ModelName must equal modelClass in the YAML. func (PostsController) ModelName() string { return "Post" } // ConfigDir holds config_list.yaml, config_form.yaml and config_filter.yaml. func (PostsController) ConfigDir() string { return "controllers/posts" } // NewRecord returns the model the generic admin handlers query. func (PostsController) NewRecord() any { return &Post{} } // RequiredPermissions are checked before any schema or query. func (PostsController) RequiredPermissions() []string { return []string{"acme.blog.access_posts"} } // BlogSettings is the singleton row behind the plugin's settings page. type BlogSettings struct { ID uint `gorm:"column:id;primaryKey"` PostsPerPage int `gorm:"column:posts_per_page"` CommentsEnabled bool `gorm:"column:comments_enabled"` } func (BlogSettings) TableName() string { return "acme_blog_settings" } func (BlogSettings) Fillable() []string { return []string{"posts_per_page", "comments_enabled"} } // Rules validates a settings save, as a WinterCMS settings model's $rules. func (BlogSettings) Rules() map[string]string { return map[string]string{"posts_per_page": "required|integer|between:1,100"} } // BlogPlugin is the acme.blog plugin; only its backend surface is shown. type BlogPlugin struct{} var ( _ pact.AdminAssets = (*BlogPlugin)(nil) _ pact.HasPermissions = (*BlogPlugin)(nil) _ pact.HasNavigation = (*BlogPlugin)(nil) _ pact.HasSettings = (*BlogPlugin)(nil) ) func (p *BlogPlugin) ID() string { return "acme.blog" } func (p *BlogPlugin) Requires() []string { return nil } func (p *BlogPlugin) Register(app *backpack.App) error { return nil } func (p *BlogPlugin) Boot(app *backpack.App) error { return nil } // AdminControllers registers the plugin's admin controllers. func (p *BlogPlugin) AdminControllers() []pact.AdminController { return []pact.AdminController{PostsController{}} } // AdminFS is the plugin's embedded controllers/ and models/ tree. A real // plugin returns an embed.FS; the example reads the same files from testdata. func (p *BlogPlugin) AdminFS() fs.FS { return os.DirFS("testdata/docs") } // Permissions replaces registerPermissions(). func (p *BlogPlugin) Permissions() []pact.Permission { return []pact.Permission{ {Code: "acme.blog.access_posts", Tab: "acme.blog::lang.plugin.name", Label: "acme.blog::lang.permissions.posts"}, {Code: "acme.blog.access_settings", Tab: "acme.blog::lang.plugin.name", Label: "acme.blog::lang.permissions.settings"}, } } // Navigation replaces registerNavigation(). func (p *BlogPlugin) Navigation() []pact.NavigationItem { return []pact.NavigationItem{{ Code: "blog", Label: "acme.blog::lang.plugin.name", Icon: "icon-pencil", Permissions: []string{"acme.blog.access_posts"}, Controller: "acme.blog.posts", SideMenu: []pact.NavigationItem{ {Code: "posts", Label: "acme.blog::lang.posts.title", Controller: "acme.blog.posts"}, }, }} } // Settings replaces registerSettings(). func (p *BlogPlugin) Settings() []pact.SettingsItem { return []pact.SettingsItem{{ Code: "blog", Label: "acme.blog::lang.settings.label", Description: "acme.blog::lang.settings.description", Category: "acme.blog::lang.plugin.name", Icon: "icon-pencil", Model: "BlogSettings", Permissions: []string{"acme.blog.access_settings"}, Form: "models/settings/fields.yaml", NewModel: func() any { return &BlogSettings{} }, }} } ``` `summer make:admin-controller` writes the controller type and its four YAML files: ```sh summer make:admin-controller acme.blog Posts ``` The controller ID maps to the admin API path: `acme.blog.posts` is served under `/api/v1/acme/blog/posts`. The admin prefix is `backend.uri`, `/backend` by default. A model's `Fillable` method decides which form fields the API may write; see [Forms](/docs/backend/forms.md). ## Compilation at boot At start-up `cabana.Activate` compiles every plugin's controllers, settings, navigation and permissions once. Any schema mistake stops the start-up with an error that names the plugin, the controller and the file: an unknown YAML key, a `modelClass` that does not match `pact.AdminController.ModelName`, a list column the model does not have, an unknown field type. Nothing is parsed per request. The example below activates the admin for the plugin above: ```go cfg, err := compass.Open(compass.Options{Dir: "config", Env: "development", Environ: []string{}}) if err != nil { fmt.Println(err) return } // Set through SUMMER_ADMIN__JWT__SECRET in a real deployment. _ = cfg.Set("admin.jwt.secret", "test-only-secret-with-at-least-32-bytes") _ = cfg.Set("backend.uri", "/admin") routes, err := cabana.Activate(backpack.New(cfg), []party.Plugin{&BlogPlugin{}}) if err != nil { fmt.Println(err) return } fmt.Println(routes.Prefix) editor := &bouncer.Principal{ID: 7, Backend: true, PermissionGrants: map[string]bool{"acme.blog.*": true}} fmt.Println(cabana.Allows(editor, PostsController{}.RequiredPermissions())) fmt.Println(cabana.Allows(editor, []string{"acme.shop.access_orders"})) // Output: // /admin // true // false ``` `admin.jwt.secret` is required as soon as any plugin registers an admin controller. With no admin controllers, no admin routes exist and no secret is needed. ## The admin API Every controller gets the same routes, relative to `/api/v1/{vendor}/{plugin}/{controller}`: | Method and path | Does | |-----------------|------| | `GET /schema/list`, `GET /schema/form` | The list and form schemas, translated into the request locale. | | `GET /` | Lists records with search, sort, filters and pagination, allowed only on columns the schema declares. | | `POST /` | Creates a record. | | `GET /{id}`, `PUT /{id}`, `DELETE /{id}` | Shows, updates and deletes a record. | | `POST /bulk-delete` | Deletes a set of records in one transaction. | The cabana README lists the full route table, including relation, options, widget, toolbar and partial routes. Every response uses one JSON envelope (`cabana.Envelope`), and a validation failure is a 422 `validation_failed` error with messages per field. Request parameters never reach SQL directly: search, sort and filters apply only to declared columns, and a write passes only the form's writable fields, filled through `lagoon.Fill` and validated through `lagoon.Validate` in a transaction. ## Hooks Behaviour overrides such as `formBeforeCreate` or `listExtendQuery` become optional interfaces on the controller. cabana checks for each one and calls it at the matching point: | Interface | Runs | |-----------|------| | `pact.ListExtendQuery` | Scopes every list query, for example to the administrator's own records. | | `pact.FormExtendQuery` | Scopes every show, update and delete lookup, so a record outside the scope is a 404. | | `pact.FormBeforeCreate`, `pact.FormAfterCreate` | Around a create, inside its transaction. | | `pact.FormBeforeUpdate`, `pact.FormAfterUpdate` | Around an update, inside its transaction. | | `pact.FormBeforeDelete`, `pact.FormAfterDelete` | Around a delete, inside its transaction. | | `pact.DropdownOptionsProvider` | Supplies the options of a `dropdown` field that names a method. | Scope reads and writes with `pact.ListExtendQuery` and `pact.FormExtendQuery` rather than checking in a hook: the scope then applies to every route, including relation and action routes. ## Toolbar actions `toolbar.buttons` in `config_list.yaml` lists the built-in `create` and `delete` and any action the controller registers through `pact.HasAdminActions`. See [Partials and widgets](/docs/backend/partials-and-widgets.md) for actions and the rest of the extension points. # Forms Source: /docs/backend/forms.html Describe admin forms in config_form.yaml and fields.yaml, with the supported field types, spans, tabs, dropdown options and create or update contexts. The Form behaviour's `config_form.yaml` and the model's `fields.yaml` keep their WinterCMS shape. [cabana](/docs/api/cabana.md) compiles them strictly at boot: an unknown key, field type, span or size stops the start-up with an error naming the file and the field, so a WinterCMS option that SummerCMS does not implement is never ignored silently. ## config_form.yaml The controller's form configuration names the fields file with a WinterCMS path, the model class, and where the SPA goes after a save: ```yaml name: acme.blog::lang.posts.form form: ~/plugins/acme/blog/models/post/fields.yaml modelClass: Post defaultRedirect: acme/blog/posts create: redirect: acme/blog/posts/update/:id redirectClose: acme/blog/posts update: redirect: acme/blog/posts redirectClose: acme/blog/posts ``` `modelClass` must equal the controller's `pact.AdminController.ModelName`. The `~/plugins///` prefix points into the plugin's own embedded tree. ## fields.yaml ```yaml fields: title: label: acme.blog::lang.posts.title_column type: text span: left required: true slug: label: acme.blog::lang.posts.slug type: text span: right context: update comment: acme.blog::lang.posts.slug_comment status: label: acme.blog::lang.posts.status type: dropdown span: left options: draft: acme.blog::lang.posts.draft published: acme.blog::lang.posts.published published: label: acme.blog::lang.posts.published type: switch span: right content: label: acme.blog::lang.posts.content type: textarea size: large tab: acme.blog::lang.posts.tab_content ``` The compiled schema keeps the fields in file order, with their labels as translation keys until a request asks for them in its locale: ```go fsys := os.DirFS("testdata/docs") form, err := cabana.CompileForm("acme.blog", PostsController{}, fsys) if err != nil { fmt.Println(err) return } for _, f := range form.Fields { fmt.Printf("%s %s span=%q tab=%q required=%v options=%d\n", f.Name, f.Type, f.Span, f.Tab, f.Required, len(f.Options)) } // Output: // title text span="left" tab="" required=true options=0 // slug text span="right" tab="" required=false options=0 // status dropdown span="left" tab="" required=false options=2 // published switch span="right" tab="" required=false options=0 // content textarea span="" tab="acme.blog::lang.posts.tab_content" required=false options=0 ``` ### Field types | Type | Renders | |------|---------| | `text`, `textarea`, `number` | Text inputs. | | `checkbox`, `switch` | Booleans. | | `dropdown` | A select. Options are a map in the YAML, or the name of a method the controller answers through `pact.DropdownOptionsProvider`. | | `relation` | A belongsTo or belongsToMany picker; see [Relation manager](/docs/backend/relation-manager.md). | | `relation-manager` | An embedded list of related records; see [Relation manager](/docs/backend/relation-manager.md). | | `widget` | A plugin custom element with a server action; see [Partials and widgets](/docs/backend/partials-and-widgets.md). | | `partial` | A server-rendered template; see [Partials and widgets](/docs/backend/partials-and-widgets.md). | The WinterCMS widgets that are not in this list (the rich editor, the media finder, the repeater, the file upload and the others) are not provided. A field with one of those types stops the start-up. ### Field options A field takes `label`, `comment`, `type`, `required`, `default`, `tab`, `span` (`left`, `right`, `full`, `auto`, `row`), `size` (`tiny`, `small`, `large`, `huge`, `giant`), `context`, `attributes` (scalar HTML attributes for the input), `options` and `emptyOption`, plus `nameFrom` and `relation` on relation fields. WinterCMS keys outside this set, such as `readOnly`, `disabled`, `trigger` or `dependsOn`, are refused. `context: update` shows a field only on the update form, and `context: create` only on the create form; a list of contexts is also accepted. The context is enforced on the server too: a field that is hidden on a form is never written by that form's save, whatever the request body holds. ## What a save may write The form's writable fields are bound to model columns at boot. A save passes only those fields that are also in the model's `Fillable` list, drops unknown keys, case variants and nested objects, and fills the model with `lagoon.Fill` (see [Models](/docs/database/models.md)). The model's validation rules (a `Rules` method returning `lagoon.Validate` rule strings) and the form's `required` flags are checked in the save's transaction, and a failure is a 422 with messages per field. A value that does not fit its column is also a 422 on that field. # Lists and filters Source: /docs/backend/lists-and-filters.html Describe admin lists in config_list.yaml and columns.yaml, with search, sorting, pagination options and switch, date range and scope filters. The List behaviour's `config_list.yaml`, the model's `columns.yaml` and the Filter widget's `config_filter.yaml` keep their WinterCMS shape. [cabana](/docs/api/cabana.md) compiles them at boot and runs every list query itself, so search, sort and filter parameters from the request apply only to what the YAML declares. ## config_list.yaml ```yaml list: ~/plugins/acme/blog/models/post/columns.yaml modelClass: Post title: acme.blog::lang.posts.title recordUrl: acme/blog/posts/update/:id recordsPerPage: 20 perPageOptions: [20, 50, 100] showCheckboxes: true defaultSort: column: published_at direction: desc filter: config_filter.yaml toolbar: buttons: [create, delete] search: prompt: backend::lang.list.search_prompt ``` | Key | Sets | |-----|------| | `list` | The columns file, with a WinterCMS `~/plugins/...` path into the plugin. | | `modelClass` | Must equal the controller's model name. | | `title`, `noRecordsMessage` | Translation keys shown by the SPA. | | `recordUrl` | Where a click on a row goes; `:id` is replaced. | | `recordsPerPage`, `perPageOptions` | The page size and the sizes a user may pick. | | `showCheckboxes`, `showSetup`, `showSorting`, `showSearch` | Which list controls appear. | | `defaultSort` | `column` and `direction` of the initial order. | | `toolbar` | `buttons` (the built-in `create` and `delete` and registered actions) and `search.prompt`. | | `filter` | The filter file, relative to the controller's directory. | | `headerPartial` | A server-rendered strip above the list; see [Partials and widgets](/docs/backend/partials-and-widgets.md). | ## columns.yaml ```yaml columns: title: label: acme.blog::lang.posts.title_column searchable: true published: label: acme.blog::lang.posts.published type: switch published_at: label: acme.blog::lang.posts.published_at type: datetime ``` A column takes `label`, `type` (`text`, `datetime` or `switch`), `searchable` (default false) and `sortable` (default true, as in WinterCMS), and, for a column read through a relation, `relation` and `select`. Other WinterCMS column types and options are refused at boot. The compiled list keeps the columns in file order: ```go // The plugin embeds its controllers/ and models/ trees; the example reads // the same files from testdata. fsys := os.DirFS("testdata/docs") list, err := cabana.CompileList("acme.blog", PostsController{}, fsys) if err != nil { fmt.Println(err) return } fmt.Println(list.Title, list.RecordsPerPage, list.PerPageOptions, list.ToolbarButtons) for _, c := range list.Columns { fmt.Printf("column %s type=%q searchable=%v sortable=%v\n", c.Key, c.Type, c.Searchable, c.Sortable) } for _, f := range list.Filters { fmt.Printf("filter %s type=%s column=%s\n", f.Name, f.Type, f.Column) } // Output: // acme.blog::lang.posts.title 20 [20 50 100] [create delete] // column title type="" searchable=true sortable=true // column published type="switch" searchable=false sortable=true // column published_at type="datetime" searchable=false sortable=true // filter published type=switch column=published // filter published_at type=daterange column=published_at ``` Search runs over the searchable columns only; sort accepts only sortable columns. A request that names any other column is refused, never passed to SQL. ## Filters `config_filter.yaml` lists scopes. Three filter types are supported: ```yaml scopes: published: label: acme.blog::lang.posts.published type: switch column: published published_at: label: acme.blog::lang.posts.published_at type: daterange column: published_at ``` | Type | Filters | |------|---------| | `switch` | A boolean `column`. With `options`, the two values are the options' keys, kept as typed scalars. | | `daterange` | A date or timestamp `column` between two dates. | | scope (a `scope` key with `modelClass` and `nameFrom`) | Records by a model-backed choice. The model implements `pact.FilterScope`, whose `FilterScopes` lists the scope names it answers, and `pact.FilterOptions` for the choices the SPA loads. | WinterCMS `conditions` SQL fragments are not supported; use a `column` or a scope the model implements. A scope filter whose name the model does not list in `FilterScopes` stops the start-up, so request text can never select another method. ## Scoping every list To restrict which records an administrator sees at all, implement `pact.ListExtendQuery` on the controller. It receives the list query before search, filters and pagination are applied, so the restriction holds for every request. See [Admin controllers](/docs/backend/admin-controllers.md) for the other hooks. # Relation manager Source: /docs/backend/relation-manager.html Edit belongsTo and belongsToMany relations in admin forms and manage linked records with config_relation.yaml, bound to models the controller names. WinterCMS edits relations in two ways: a `relation` form field that picks the related record, and the Relation behaviour, which embeds a list of linked records with link and unlink buttons. [cabana](/docs/api/cabana.md) has both. The one rule that differs from WinterCMS: the framework never guesses a table, pivot or foreign key name. The controller supplies every name, and a missing or wrong one stops the start-up. ## Relation fields A `type: relation` field in `fields.yaml` picks a belongsTo record or a set of belongsToMany records. `nameFrom` names the related model's label column: ```yaml category: label: acme.blog::lang.posts.category type: relation nameFrom: name ``` The controller implements `cabana.FieldRelationProvider` and returns a `cabana.FieldRelationContract` per field: its `Kind` (`belongsTo` or `belongsToMany`), a factory for the related model, and the `ForeignKey` of a belongsTo or the pivot model and its two key columns for a belongsToMany. An optional `OrderColumn` on the pivot stores the order in which the administrator picked the records. The SPA loads the choices, paginated, from `.../fields/{field}/options`, and every record response carries the display labels of the linked records. A controller that implements `pact.RelationExtendOptionsQuery` narrows the choices, and the same scoped query rechecks the submitted IDs on save, so a record it does not offer cannot be attached. ## Relation managers A `type: relation-manager` field embeds a relation manager in the form. Its `relation` key names an entry in the controller's `config_relation.yaml`, which describes the two panels: the linked records (`view`) and the candidates shown when linking (`manage`): ```yaml editors: label: acme.blog::lang.posts.editors view: list: columns: name: label: acme.blog::lang.editors.name email: label: acme.blog::lang.editors.email toolbarButtons: link|unlink showSearch: true manage: list: columns: name: label: acme.blog::lang.editors.name showSearch: true ``` The controller implements `cabana.AdminRelationContractProvider` and returns a `cabana.RelationContract` per relation: the related and pivot model factories, the pivot's two foreign keys, a map from column names in the YAML to physical columns, and optionally the pivot columns a hook may set and a function that excludes candidate IDs, such as the parent itself. A relation in the YAML without a contract, a contract without a relation, or a relation without a `relation-manager` field stops the start-up. `cabana.RelationService` serves the panels: linked records, link candidates, link and unlink, under `.../{id}/relations/{name}`. Link and unlink run in a transaction. `pact.RelationExtendManageQuery` scopes the candidates, and `pact.RelationBeforeLink` can check or fill pivot columns before a link is written. ## Relations in lists A list column can show a related value with `relation` and `select` in `columns.yaml`; see [Lists and filters](/docs/backend/lists-and-filters.md). A controller that maps a relation column to a physical column itself implements `pact.ListRelationColumnMapper`. # Users and permissions Source: /docs/backend/users-and-permissions.html Sign administrators in with JWT and cookie auth, declare permissions and navigation, and manage administrators from the console. The admin keeps WinterCMS's backend user model: the `backend_users` and `backend_user_roles` tables, roles with permission grants, and superusers who pass every check. [cabana](/docs/api/cabana.md) signs administrators in and checks their permissions; the tables are created by the framework migrations that `migrate` runs. ## Signing in `POST /api/v1/auth/login` checks the login and password against `backend_users` and issues a JWT for the admin audience, signed with `admin.jwt.secret`. Login attempts are throttled per `admin.login.max_attempts` and `admin.login.decay_minutes`. `POST .../auth/refresh` reissues a token inside the refresh window, and `POST .../auth/logout` revokes the current token by blacklisting its ID in `backend_jwt_blacklist`. The admin API accepts the token two ways: - API clients send `Authorization: Bearer `. - The admin SPA sends `X-Requested-With: XMLHttpRequest` and receives the token in an HttpOnly, SameSite=Strict cookie. A cookie-authenticated request that changes state must carry that header, which blocks cross-site request forgery. The guard is registered in [bouncer](/docs/api/bouncer.md) under the name `backend` and is the middleware of every admin route except login, refresh and the language bundle. See [Authentication](/docs/services/authentication.md) for tokens and guards in general. The admin keys go in `config/admin.yaml`, with the secret in the environment (`SUMMER_ADMIN__JWT__SECRET`): ```yaml jwt: ttl: 60 refresh_ttl: 20160 password: bcrypt_cost: 12 login: max_attempts: 5 decay_minutes: 1 ``` The cookie carries the Secure attribute. `backend.cookie_secure: false` drops it for plain-HTTP development and is refused in the `production` environment. ## Permissions A plugin declares its permissions with `pact.HasPermissions`, the Go form of `registerPermissions`, and its menu entries with `pact.HasNavigation`: ```go // Permissions replaces registerPermissions(). func (p *BlogPlugin) Permissions() []pact.Permission { return []pact.Permission{ {Code: "acme.blog.access_posts", Tab: "acme.blog::lang.plugin.name", Label: "acme.blog::lang.permissions.posts"}, {Code: "acme.blog.access_settings", Tab: "acme.blog::lang.plugin.name", Label: "acme.blog::lang.permissions.settings"}, } } ``` ```go // Navigation replaces registerNavigation(). func (p *BlogPlugin) Navigation() []pact.NavigationItem { return []pact.NavigationItem{{ Code: "blog", Label: "acme.blog::lang.plugin.name", Icon: "icon-pencil", Permissions: []string{"acme.blog.access_posts"}, Controller: "acme.blog.posts", SideMenu: []pact.NavigationItem{ {Code: "posts", Label: "acme.blog::lang.posts.title", Controller: "acme.blog.posts"}, }, }} } ``` A controller's `pact.AdminPermissioned.RequiredPermissions` are checked before any schema is served or query runs, and navigation and settings entries are filtered by the permissions they name, so an administrator sees only what they may open. `cabana.Allows` is the check: superusers pass, a grant ending in `.*` matches every code with that prefix, and an empty requirement list allows any signed-in administrator. The last lines of the activation example on [Admin controllers](/docs/backend/admin-controllers.md) show it. Actions registered through `pact.HasAdminActions` may name extra permissions, checked on top of the controller's. ## Managing administrators The application binary has two commands for operators: ```sh ./bin/acme admin:create --email admin@example.com --password '' --superuser ./bin/acme admin:reset-password admin@example.com --password '' ``` `admin:create` creates an activated administrator; `--login` defaults to the lower-cased email and `--role ` assigns a role. `admin:reset-password` takes a login or an email, sets the password and revokes every token issued before the reset. Passwords are hashed with bcrypt at `admin.password.bcrypt_cost`, so hashes copied from a WinterCMS database keep working. > [!TIP] > Pass the password through an environment variable or a prompt of your shell rather than typing it on the command line, where it stays in the shell history. # Settings Source: /docs/backend/settings.html Declare singleton settings pages with pact.SettingsItem, backed by a model, a fields.yaml form and validation rules, and read them from plugin code. A WinterCMS settings model extends `SettingModel`, stores its values in `system_settings` and registers its page with `registerSettings`. In SummerCMS a settings page is a singleton row of the plugin's own table, edited through a form described in `fields.yaml`, and declared with `pact.HasSettings`. ## Declaring a settings page `pact.HasSettings` returns `pact.SettingsItem` entries. Each names the page's code, label, category and icon for the settings index, the permissions it needs, the form file inside the plugin's embedded tree, and a factory for its model: ```go // Settings replaces registerSettings(). func (p *BlogPlugin) Settings() []pact.SettingsItem { return []pact.SettingsItem{{ Code: "blog", Label: "acme.blog::lang.settings.label", Description: "acme.blog::lang.settings.description", Category: "acme.blog::lang.plugin.name", Icon: "icon-pencil", Model: "BlogSettings", Permissions: []string{"acme.blog.access_settings"}, Form: "models/settings/fields.yaml", NewModel: func() any { return &BlogSettings{} }, }} } ``` The model is a GORM struct with a `Fillable` list and a `Rules` method; [cabana](/docs/api/cabana.md) refuses at start-up a settings model without either: ```go // BlogSettings is the singleton row behind the plugin's settings page. type BlogSettings struct { ID uint `gorm:"column:id;primaryKey"` PostsPerPage int `gorm:"column:posts_per_page"` CommentsEnabled bool `gorm:"column:comments_enabled"` } ``` ```go // Rules validates a settings save, as a WinterCMS settings model's $rules. func (BlogSettings) Rules() map[string]string { return map[string]string{"posts_per_page": "required|integer|between:1,100"} } ``` The form uses the same field types and options as a controller form; see [Forms](/docs/backend/forms.md): ```yaml fields: posts_per_page: label: acme.blog::lang.settings.posts_per_page type: number span: left default: 10 comments_enabled: label: acme.blog::lang.settings.comments_enabled type: switch span: right ``` Create the table in a migration like any other; see [Migrations](/docs/database/migrations.md). ## How values are stored The settings row is the one with ID 1. Before it exists, the page shows the fields' `default` values and the API reports that the row does not exist yet; the first save creates it. A save writes only fillable fields, validates them with the model's rules and the form's `required` flags, and runs in a transaction, as a controller form save does. The admin API serves the page at `/api/v1/settings/{code}` (values) and `.../settings/{code}/schema` (the form), and `GET .../settings` lists the pages the administrator may open. ## Reading settings in plugin code Settings are an ordinary table, so plugin code reads them with GORM: ```go svc.SetGate(beachcomber.GateFunc(func(ctx context.Context, db *gorm.DB) bool { var enabled bool err := db.WithContext(ctx).Raw(`SELECT search_enabled FROM acme_blog_settings WHERE id = 1`).Scan(&enabled).Error return err == nil && enabled })) ``` That example reads a flag from a settings row inside a search gate, treating a failed read as off. A setting that changes rarely and that operators, not administrators, own belongs in configuration instead; see [Configuration](/docs/services/configuration.md). # Partials and widgets Source: /docs/backend/partials-and-widgets.html Extend admin screens with server-rendered partials, plugin JavaScript and CSS, form widgets backed by server actions, and custom toolbar buttons. WinterCMS controllers extend their screens with partials, `addJs` and `addCss`, custom form widgets and toolbar buttons that call AJAX handlers. The SummerCMS admin is a single-page app, so [cabana](/docs/api/cabana.md) keeps these extension points in a form the SPA can render safely: partials arrive as an allowlisted node tree, plugin scripts are declared files, and every button or widget runs a server action through a cabana-owned route. ## Server-rendered partials Two places accept a partial: - `headerPartial: ` in `config_list.yaml`, a strip above the list; - a `type: partial` field with `path: ` in `fields.yaml`. Both render `{ConfigDir}/_.htm` with Go's `html/template`. WinterCMS `$/` and `~/` partial paths are not supported. The template's data is `.Data`, the value the controller's `pact.AdminPartialData` returns for that partial name; for a form partial on an existing record, cabana passes the record it loaded through the controller's `pact.FormExtendQuery` scope. `trans ""` translates a phrase key in the request locale. A statistics strip above a list, using the SPA's partial style classes: ```html
{{- range .Data.Items -}}
{{ trans .Label }}
{{ .Count }}
{{- end -}}
``` The view model must be a struct built for the template. cabana refuses a view model that holds the controller's model or any other GORM model, anywhere inside it, and refuses pre-escaped `html/template` content types, so every record value stays escaped. The rendered HTML is parsed and walked through an allowlist before it reaches the SPA: script, style, iframe, form and similar elements are removed with their content, unknown elements are unwrapped, `id`, `style` and event handler attributes are dropped, and links and images must be same-origin paths. Output is capped at 64 KiB, 2000 nodes and a depth of 32. The cabana README lists the allowed elements, attributes and style classes. ## Plugin JavaScript and CSS A controller that implements `pact.AdminClientAssets` names `.js`, `.mjs` and `.css` files under its plugin's `assets/` directory, the Go form of `addJs` and `addCss`. They are read from the embedded tree at start-up (a missing file stops it) and served from `/assets/{vendor}/{plugin}/...` with a content hash in the URL, the admin Content-Security-Policy (`script-src 'self'`) and `nosniff`. Only declared files are reachable; the YAML and templates never are. Plugin CSS may use only the SPA's public CSS variables (`--c-bg`, `--c-surface`, `--c-text`, `--c-primary` and the others the cabana README lists), which switch with dark mode. Do not hardcode colours and do not rely on the SPA's utility classes. ## Form widgets A `type: widget` field puts a plugin custom element in the form and connects it to a server action: ```yaml lookup: label: acme.blog::lang.posts.lookup type: widget widget: acme-blog-lookup action: lookup fill: [title, slug] ``` - `widget` is the custom element's tag, which must start with the plugin's `{vendor}-{plugin}-` prefix. A plugin script declared through `pact.AdminClientAssets` defines the element. - `action` names an action the controller registers through `pact.HasAdminActions`. - `fill` lists the writable scalar fields of the same form the action may write back. The SPA posts the widget's values to `.../widgets/{field}`. cabana checks the CSRF header, the controller's and the action's permissions and the record scope, then calls the action's `Run` with a `pact.AdminActionInput`. The answer's `pact.AdminActionResult` carries a message and the fill values; keys outside `fill` and values that are not scalars are dropped before the response is written. An action may return a `cabana.ValidationError` to answer 422 on a field. ## Toolbar actions Names in `toolbar.buttons` of `config_list.yaml`, other than the built-in `create` and `delete`, are actions the controller registers through `pact.HasAdminActions`. Each needs a label. A toolbar action runs with an empty body and no record IDs, so it can never become an unscoped lookup of IDs the client chose. The list schema lists only the actions the administrator may run. An unknown action name, a widget tag outside the plugin's prefix or a fill key that is not a writable scalar field stops the start-up. # Admin SPA Source: /docs/backend/admin-spa.html How boardwalk serves the embedded Vue admin SPA under backend.uri, how the SPA talks to the admin API, and how its TypeScript types come from OpenAPI. WinterCMS renders its backend on the server with layouts, partials and the AJAX framework. SummerCMS replaces that with one Vue 3 single-page app, built once and embedded in the binary by [boardwalk](/docs/api/boardwalk.md). Plugins do not ship admin pages: they ship YAML and, when they need them, partials and scripts, and the SPA renders every controller from the schemas the admin API serves. ## Serving cabana mounts the SPA under the admin prefix, `backend.uri` (`/backend` by default; one or more lowercase path segments). `boardwalk.Handler` serves the build: - Any path under the prefix that is not a file and not under `api/` returns `index.html`, so the SPA's own routes work on reload. - Paths under `api/` that no API route matches return the admin API's JSON `not_found` error, never the SPA. - Hashed files under `assets/` are cached for a long time; `index.html` is never cached. - Every response carries a restrictive Content-Security-Policy, frame denial, `nosniff`, a same-origin referrer policy and `noindex, nofollow`. The build is path-agnostic: `index.html` holds a placeholder (`boardwalk.BaseToken`) that `boardwalk.RewriteIndex` replaces with the prefix once, when the handler is built. A build without the placeholder fails the start-up. A plugin route under the admin prefix also fails the start-up: the SPA and the admin API own that whole path. ## How the SPA talks to the server The SPA signs in through the admin API and keeps the token in the HttpOnly cookie described on [Users and permissions](/docs/backend/users-and-permissions.md). For each screen it loads the controller's localized schema (`schema/list`, `schema/form`), then the records, and renders the fields and columns the schema names. Strings come from `GET /api/v1/lang`, the `backend::lang` bundle in the request locale, with CLDR plural forms. ## Types from OpenAPI The admin API is described by swag annotations in cabana. `scripts/check-admin-openapi.sh` generates the OpenAPI document (`admin/openapi/admin.json`) from them and the SPA's TypeScript types (`admin/src/api/schema.d.ts`) from the document, so the SPA's API client is checked against the server's shapes at compile time. `--check` fails when either committed file is out of date: ```sh scripts/check-admin-openapi.sh --check ``` ## Building the SPA The SPA's source is the `admin/` Vite project. `npm --prefix admin run build` type-checks it and writes the build to `modules/boardwalk/dist`, which the next `go build` embeds. An application that only uses the framework never builds the SPA: the build is committed with the framework. # Models Source: /docs/database/models.html Define models as GORM structs with lagoon helpers for mass assignment, hidden columns and lifecycle hooks, and keep the models package a leaf. A WinterCMS model extends Eloquent and describes its behaviour with properties such as `$fillable`, `$hidden` and `$jsonable`. A SummerCMS model is a plain GORM struct in the plugin's `models` package, and [lagoon](/docs/api/lagoon.md) supplies the Eloquent conventions that GORM does not have: allow-listed mass assignment, JSON and encrypted columns, Laravel-style validation and pagination. The data layer supports PostgreSQL only and puts no requirement on the database's default locale. A list that must sort text in one language's order passes a collation to `lagoon.OrderBy`, as described in [Queries and pagination](/docs/database/queries-and-pagination.md#sorting-with-a-collation). ## Defining a model `summer make:model acme.blog Post` writes `models/post.go` and a migration that creates the table. The model is an ordinary struct with `gorm` column tags. Keep the WinterCMS table name with a `TableName` method, and use the lagoon column types where Eloquent used casts: ```go // Post is the acme.blog post model: a plain GORM struct with lagoon column // types. type Post struct { ID uint `gorm:"column:id;primaryKey" json:"id"` Title string `gorm:"column:title" json:"title"` Slug string `gorm:"column:slug" json:"slug"` Views int `gorm:"column:views" json:"views"` Tags lagoon.Jsonable[[]string] `gorm:"column:tags" json:"-"` APIToken lagoon.Encrypted `gorm:"column:api_token" json:"-"` DeletedAt gorm.DeletedAt `gorm:"column:deleted_at" json:"-"` Categories []Category `gorm:"many2many:acme_blog_post_categories" json:"-"` } ``` ```go // TableName keeps the WinterCMS table name. func (Post) TableName() string { return "acme_blog_posts" } ``` The column types are described in [Casts and validation](/docs/database/casts-and-validation.md). Relations are ordinary GORM fields; see [Relations](/docs/database/relations.md). ## Mass assignment `$fillable` becomes a `Fillable` method that returns the column names mass assignment may set. The model implements `lagoon.HasFillable`: ```go // Fillable is the Go form of $fillable: the keys mass assignment may set. func (Post) Fillable() []string { return []string{"title", "views"} } ``` `lagoon.Fill` copies the keys of a request map onto the model, but only keys that are also in the allow-list you pass. Other keys are dropped without an error, as Eloquent does. Outside production, pass `false` as the last argument and each dropped key is logged once, which catches typos in development. A value that does not fit its column, such as text for an integer field, is a `lagoon.FillTypeError` whose `Key` names the column, so a handler can answer it as a validation error on that field: ```go input := map[string]any{"title": "Hello", "views": 3, "slug": "forged"} var post Post // production=false logs each dropped key once, to catch typos in development. if err := lagoon.Fill(&post, post.Fillable(), input, true); err != nil { fmt.Println(err) } fmt.Printf("%q %q %d\n", post.Title, post.Slug, post.Views) err := lagoon.Fill(&post, post.Fillable(), map[string]any{"views": "many"}, true) var typeErr *lagoon.FillTypeError if errors.As(err, &typeErr) { fmt.Println("invalid value for", typeErr.Key) } // Output: // "Hello" "" 3 // invalid value for views ``` `lagoon.Fill` matches keys by the GORM column name, not the Go field name. A number decoded with `json.Decoder.UseNumber` fills integer and float fields; a fraction or an overflow for an integer field is a `lagoon.FillTypeError`. ## Creating a record A handler usually validates the input, fills the model and creates it. `lagoon.Validate` is described in [Casts and validation](/docs/database/casts-and-validation.md): ```go rules := map[string]string{ "title": "required|max:255|unique:acme_blog_posts", "views": "nullable|integer|min:0", } var post Post errs, err := lagoon.Validate(ctx, db, &post, rules, input, nil) if err != nil || errs != nil { return nil, errs, err } if err := lagoon.Fill(&post, post.Fillable(), input, false); err != nil { return nil, nil, err } if err := db.WithContext(ctx).Create(&post).Error; err != nil { return nil, nil, err } return &post, nil, nil ``` ## Hidden columns `$hidden` becomes a `Hidden` method, the `lagoon.HasHidden` interface. It lists the columns that must never appear in a JSON response. The list documents the rule; the `json:"-"` tag on each field is what enforces it, because the Go JSON encoder reads struct tags, not methods: ```go // Hidden is the Go form of $hidden. The json:"-" tags are what keep these // columns out of JSON; the list documents them for tooling. func (Post) Hidden() []string { return []string{"tags", "api_token", "deleted_at"} } ``` ```go post := Post{ID: 1, Title: "Hello", APIToken: lagoon.NewEncrypted("s3cret")} out, _ := json.Marshal(post) fmt.Println(string(out)) var _ lagoon.HasHidden = post // Output: // {"id":1,"title":"Hello","slug":"","views":0} ``` A `lagoon.Encrypted` column is also redacted when it is marshalled by mistake, so a secret never reaches a response even without the tag. > [!TIP] > Ported endpoints rarely marshal the model itself. Build a response struct with exactly the fields the API returns, and use the [wire](/docs/api/wire.md) helpers for Carbon-style timestamps and `[]` for empty lists. See [Routing](/docs/services/routing.md). ## Lifecycle hooks GORM calls hook methods by name, so a model that needs `beforeCreate` or `beforeDelete` defines `BeforeCreate` or `BeforeDelete` with GORM's signature. lagoon names them as interfaces (`lagoon.HasBeforeCreate`, `lagoon.HasBeforeSave`, `lagoon.HasBeforeDelete`, `lagoon.HasAfterDelete`) so a compile-time assertion can check the signature. `lagoon.HasBeforeValidate` is the WinterCMS `beforeValidate` hook. GORM does not call it: the admin form and settings saves call it before they validate, and your own handlers call it when they need it. A hook that only touches the model itself stays on the model: ```go // BeforeCreate fills the slug from the title. A hook that only touches the // model stays on the model. func (p *Post) BeforeCreate(tx *gorm.DB) error { if p.Slug == "" { p.Slug = strings.ReplaceAll(strings.ToLower(strings.TrimSpace(p.Title)), " ", "-") } return nil } ``` GORM runs the hook inside the transaction of the write, and an error from it aborts the write. ## The models package is a leaf A plugin's `models` package never imports another package of the same plugin. `summer build` fails when it does. This keeps the dependency graph one way: `classes`, `controllers`, `console` and `jobs` import `models`, never the reverse. The rule decides where two kinds of WinterCMS model code go when you port a plugin: - Casts and other types the model owns, such as JSON value objects, move into `models`. - A hook that calls a service, such as a model event that dispatches a job, broadcasts or clears a cache, becomes a GORM callback registered from the plugin's `Boot` step or from `classes`. See [Transactions](/docs/database/transactions.md) for registering callbacks with `lagoon.OnDatabase` and deferring their side effects until the write commits. # Migrations Source: /docs/database/migrations.html Ship a plugin's schema as an ordered gormigrate set through pact.HasMigrations, and run, inspect and roll back migrations per plugin. WinterCMS plugins keep their schema in the `updates/` directory and list the steps in `version.yaml`. A SummerCMS plugin keeps the same `updates/` directory, but each step is a Go migration in a [gormigrate](https://github.com/go-gormigrate/gormigrate) set that the plugin returns from `pact.HasMigrations`. [lagoon](/docs/api/lagoon.md) runs the sets and records each plugin's history in its own table. ## Writing migrations `summer make:model` writes a create-table migration with the model, and `summer make:migration` writes an empty one to fill in: ```sh summer make:migration acme.blog AddPublishedAt ``` The file is `updates/_add_published_at.go`, with a migration ID that starts with the same timestamp. `summer build` generates the plugin's list of migrations in file name order, so the timestamp is also the order in which they run. The scaffolded plugin returns that generated list from its `Migrations` method. A migration has an ID, a `Migrate` function and a `Rollback` function, both given the transaction to run in. Write DDL as SQL with `tx.Exec`: the migration then says exactly what the database gets, and it keeps working when the model struct changes later. This is the whole set of an example plugin, written out by hand: ```go // Migrations returns the plugin's schema as an ordered gormigrate set. In a // scaffolded plugin each migration is a file in updates/ and this list is // generated in file name order. func (p *BlogPlugin) Migrations() []*gormigrate.Migration { return []*gormigrate.Migration{ { ID: "20260101000100_create_posts", Migrate: func(tx *gorm.DB) error { return tx.Exec(`CREATE TABLE acme_blog_posts ( id SERIAL PRIMARY KEY, title TEXT NOT NULL, slug TEXT NOT NULL, views INTEGER NOT NULL DEFAULT 0, tags TEXT, api_token TEXT, deleted_at TIMESTAMPTZ )`).Error }, Rollback: func(tx *gorm.DB) error { return tx.Exec(`DROP TABLE IF EXISTS acme_blog_posts`).Error }, }, { ID: "20260101000200_create_comments_and_categories", Migrate: func(tx *gorm.DB) error { for _, stmt := range []string{ `CREATE TABLE acme_blog_comments (id SERIAL PRIMARY KEY, post_id INTEGER NOT NULL, body TEXT NOT NULL, deleted_at TIMESTAMPTZ)`, `CREATE TABLE acme_blog_categories (id SERIAL PRIMARY KEY, name TEXT NOT NULL)`, `CREATE TABLE acme_blog_post_categories (post_id INTEGER NOT NULL, category_id INTEGER NOT NULL, sort_order INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (post_id, category_id))`, } { if err := tx.Exec(stmt).Error; err != nil { return err } } return nil }, Rollback: func(tx *gorm.DB) error { return tx.Exec(`DROP TABLE IF EXISTS acme_blog_post_categories, acme_blog_categories, acme_blog_comments`).Error }, }, } } ``` Treat an ID as permanent once the migration has run anywhere. gormigrate records the IDs it has applied, so renaming one makes it run again. ## Running migrations The application binary has the migration commands: ```sh ./bin/acme migrate ./bin/acme migrate:status ./bin/acme migrate:rollback --plugin acme.blog ``` `migrate` runs the framework's own sets first (file attachments, the admin users and roles, and the job queue), then each plugin's set in plugin activation order, so a plugin's migrations run after those of the plugins it requires. Each plugin has its own history table, `summer_migrations_` with dots replaced by underscores, as `lagoon.HistoryTableName` returns. `migrate:rollback` rolls back the last applied migration of one plugin. Without `--plugin` it picks the last activated plugin that has migrations. To fix a migration you just wrote, roll it back, edit it and run `migrate` again. `migrate:status` prints each plugin's history table and applied IDs. The same operations are Go functions, which is how tests migrate a fresh database: ```go plugins := []party.Plugin{&BlogPlugin{}} if err := lagoon.Migrate(db, plugins); err != nil { return nil, err } rows, err := lagoon.Status(db, plugins) if err != nil { return nil, err } for _, row := range rows { lines = append(lines, fmt.Sprintf("%s %s %v", row.Plugin, row.Table, row.IDs)) } if err := lagoon.RollbackLast(db, plugins, "acme.blog"); err != nil { return nil, err } ``` `lagoon.Migrate`, `lagoon.Status` and `lagoon.RollbackLast` take the activated plugins; a plugin that does not implement `pact.HasMigrations` is skipped. ## Porting version.yaml WinterCMS runs a plugin's update scripts by version number and seeds data from the same files. When you port a plugin: - Fold the existing tables into one create migration per table, matching the final PHP schema column for column, so data copied from the PHP database fits. - Keep data changes (backfills, seeds) as their own migrations, written in SQL. - Give every `Rollback` a real inverse. It is what makes `migrate:rollback` safe while you develop. The migration commands open the database through `database.dsn` and load `app.key`; see [Configuration](/docs/setup/configuration.md). # Queries and pagination Source: /docs/database/queries-and-pagination.html Query models with GORM, sort by a client-chosen column safely with lagoon.OrderBy, and return Laravel-shaped pages with lagoon.Paginate. Queries are plain GORM: `Where`, `Joins`, `Preload`, `Count`, `Find` and the rest work as the [GORM documentation](https://gorm.io/docs/) describes. [lagoon](/docs/api/lagoon.md) adds two helpers for list endpoints, which almost always let the client choose the sort order and ask for one page. Pass the request context to every query with `WithContext`, so a cancelled request stops its query. ## Sorting by a column the client names A list endpoint usually takes `?sort=title&order=desc`. Never pass those values to `Order` directly: a column name is SQL, not a bound parameter. `lagoon.OrderBy` appends the ORDER BY only when the column is in an allow-list you give it and the direction is `asc` or `desc`, and returns an error for anything else: ```go // A dry-run handle shows the SQL without a database. db, _ := gorm.Open(postgres.New(postgres.Config{DSN: "host=127.0.0.1"}), &gorm.Config{DryRun: true, DisableAutomaticPing: true}) allowed := []string{"title", "views"} q, err := lagoon.OrderBy(db.Model(&Post{}), "views", "desc", allowed) if err != nil { fmt.Println(err) return } var posts []Post fmt.Println(q.Find(&posts).Statement.SQL.String()) _, err = lagoon.OrderBy(db, "api_token", "asc", allowed) fmt.Println(err) _, err = lagoon.OrderBy(db, "title", "asc; DROP TABLE acme_blog_posts", allowed) fmt.Println(err) // Output: // SELECT * FROM "acme_blog_posts" WHERE "acme_blog_posts"."deleted_at" IS NULL ORDER BY views DESC // lagoon: order column "api_token" is not allow-listed // lagoon: order direction "asc; DROP TABLE acme_blog_posts" is not allow-listed ``` Answer the error as a validation failure (422). The column must match an allow-list entry exactly, so list the qualified name (`acme_blog_posts.title`) when the query joins another table. ## Sorting with a collation Text sorts by the database's default collation unless you pass `lagoon.Collate`. A list that must follow one language's alphabet (Polish puts Ł between L and M, for example) passes `lagoon.Collate("pl-x-icu")`, and `lagoon.OrderBy` adds a `COLLATE` clause for that column only. ICU collations named `-x-icu` exist in any PostgreSQL built with ICU support, which includes the official Docker images, so the database needs no special locale: ```go // A dry-run handle shows the SQL without a database. db, _ := gorm.Open(postgres.New(postgres.Config{DSN: "host=127.0.0.1"}), &gorm.Config{DryRun: true, DisableAutomaticPing: true}) allowed := []string{"title", "views"} // Polish alphabetical order, whatever the database's default locale. q, err := lagoon.OrderBy(db.Model(&Post{}), "title", "asc", allowed, lagoon.Collate("pl-x-icu")) if err != nil { fmt.Println(err) return } var posts []Post fmt.Println(q.Find(&posts).Statement.SQL.String()) _, err = lagoon.OrderBy(db, "title", "asc", allowed, lagoon.Collate(`pl-x-icu" ASC, (SELECT 1) --`)) fmt.Println(err) // Output: // SELECT * FROM "acme_blog_posts" WHERE "acme_blog_posts"."deleted_at" IS NULL ORDER BY title COLLATE "pl-x-icu" ASC // lagoon: order collation "pl-x-icu\" ASC, (SELECT 1) --" is not a valid collation name ``` The collation name is validated (ASCII letters, digits, `_`, `-`, `.` and `@`, at most 63 bytes) and quoted as an identifier; anything else is an error, and no SQL is built. An index only helps that ORDER BY when it is built with the same collation. ## Pagination `lagoon.Paginate` wraps the rows of one page in the envelope Laravel's paginator produces for the API: `data` and a `meta` object with `current_page`, `last_page`, `per_page` and `total`. You run the count and the page query yourself, so the query stays under your control: ```go q, err := lagoon.OrderBy(db.WithContext(ctx).Model(&Post{}), sort, dir, []string{"title", "views"}) if err != nil { return lagoon.Page[Post]{}, err // answer 422: the client asked for a column it may not sort by } var total int64 if err := q.Count(&total).Error; err != nil { return lagoon.Page[Post]{}, err } var posts []Post if err := q.Offset((page - 1) * perPage).Limit(perPage).Find(&posts).Error; err != nil { return lagoon.Page[Post]{}, err } return lagoon.Paginate(posts, page, perPage, total), nil ``` The result is a `lagoon.Page` whose `lagoon.PageMeta` marshals to the Laravel field names: ```go rows := []map[string]any{{"id": 3, "title": "Third"}} page := lagoon.Paginate(rows, 2, 2, 3) out, _ := json.Marshal(page) fmt.Println(string(out)) empty, _ := json.Marshal(lagoon.Paginate[map[string]any](nil, 1, 15, 0)) fmt.Println(string(empty)) // Output: // {"data":[{"id":3,"title":"Third"}],"meta":{"current_page":2,"last_page":2,"per_page":2,"total":3}} // {"data":[],"meta":{"current_page":1,"last_page":1,"per_page":15,"total":0}} ``` A nil slice becomes `[]`, and a zero or negative page size gives one page instead of dividing by zero. There is no `links` block. When a ported endpoint's response has a different shape, build that shape yourself: the existing clients define the contract. Clamp `page` and `per_page` from the request before you use them, for example to at least 1 and at most 100, so a client cannot ask for the whole table in one page. # Relations Source: /docs/database/relations.html Declare relations as GORM associations, write pivot tables with business columns explicitly, and cascade soft deletes inside the parent delete. Eloquent relations (`$belongsTo`, `$hasMany`, `$belongsToMany`) become GORM associations: struct fields whose type is another model, configured with `gorm` tags. Load them with `Preload` and filter through them with `Joins`, as the [GORM association documentation](https://gorm.io/docs/associations.html) describes. [lagoon](/docs/api/lagoon.md) adds two helpers for the cases GORM leaves open. ## Many-to-many with a pivot model A `many2many` field joins two models through a join table. When the join table has columns of its own, such as a sort order or a role, describe it as a model: ```go // PostCategory is the pivot model: the join table has a business column. type PostCategory struct { PostID uint `gorm:"column:post_id;primaryKey"` CategoryID uint `gorm:"column:category_id;primaryKey"` SortOrder int `gorm:"column:sort_order"` } ``` and register it for the field with `lagoon.RegisterJoinTable`, which calls GORM's `SetupJoinTable` and returns an error instead of panicking on a nil handle. GORM's association mode cannot set the extra columns, so write the pivot rows yourself: delete the post's rows and insert the new ones in one transaction, in the same transaction as the parent save when there is one. To read the rows in pivot order, join the pivot table: ```go if err := lagoon.RegisterJoinTable(db, &Post{}, "Categories", &PostCategory{}); err != nil { return nil, err } err := lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error { if err := tx.Where("post_id = ?", post.ID).Delete(&PostCategory{}).Error; err != nil { return err } rows := make([]PostCategory, len(categoryIDs)) for i, id := range categoryIDs { rows[i] = PostCategory{PostID: post.ID, CategoryID: id, SortOrder: i} } return tx.Create(&rows).Error }) if err != nil { return nil, err } var categories []Category err = db.WithContext(ctx). Joins("JOIN acme_blog_post_categories pc ON pc.category_id = acme_blog_categories.id"). Where("pc.post_id = ?", post.ID). Order("pc.sort_order"). Find(&categories).Error return categories, err ``` After `lagoon.RegisterJoinTable`, `Preload("Categories")` also loads the categories through the pivot model, without an order. > [!WARNING] > Do not order a `Preload` of a many-to-many field by a pivot column. GORM preloads the related rows in a query that does not join the pivot table, so the query fails. Use a join, as above. ## Soft deletes and cascades A model with a `gorm.DeletedAt` field is soft-deleted: `Delete` sets `deleted_at`, and queries skip deleted rows unless you call `Unscoped`. This is the SummerCMS form of the `SoftDelete` trait. WinterCMS cascades a delete to dependent records through `$hasMany` options. In SummerCMS, the parent model does it in its `BeforeDelete` hook with `lagoon.WithSoftDeleteCascade`. GORM already runs the hook inside the transaction of the parent's delete, so the cascade commits or rolls back with it, and an error from the cascade aborts the parent delete: ```go // BeforeDelete soft-deletes the post's comments in the transaction of the // post's own delete; an error aborts that delete. func (p *Post) BeforeDelete(tx *gorm.DB) error { return lagoon.WithSoftDeleteCascade(tx, func(tx *gorm.DB) error { return tx.Where("post_id = ?", p.ID).Delete(&Comment{}).Error }) } ``` Deleting a post then soft-deletes its comments in the same transaction. Files attached to a record are deleted differently, after the transaction commits; see [Attachments](/docs/database/attachments.md). # Casts and validation Source: /docs/database/casts-and-validation.html Store JSON and encrypted columns with lagoon.Jsonable and lagoon.Encrypted, and validate input with Laravel-style rule strings through lagoon.Validate. Eloquent casts a column through `$jsonable`, `$casts` and the `encrypted` cast, and WinterCMS models validate with a `$rules` array. [lagoon](/docs/api/lagoon.md) keeps both: column types that implement `sql.Scanner` and `driver.Valuer`, and a validator that reads the same rule strings. ## JSON columns `lagoon.Jsonable` is the Go form of `$jsonable`. It stores any Go value as JSON text in a `TEXT` column (not `jsonb`, so data copied from a WinterCMS database fits as it is). The value lives in `Data`; `Valid` tells SQL `NULL` apart from an empty value, because a nil slice and an empty one are different rows: ```go tags := lagoon.Jsonable[[]string]{Data: []string{"go", "cms"}, Valid: true} v, _ := tags.Value() fmt.Println(v) var none lagoon.Jsonable[[]string] // Valid false stores SQL NULL v, _ = none.Value() fmt.Println(v) var read lagoon.Jsonable[[]string] _ = read.Scan(`["winter"]`) fmt.Println(read.Get(), read.Valid) // Output: // ["go","cms"] // // [winter] true ``` Set `NullOnEmpty` on a slice or map column that should store `NULL` rather than `[]` or `{}` when it is empty. Pick the behaviour the PHP table already has. ## Encrypted columns `lagoon.Encrypted` is the `encrypted` cast. It stores AES-256-GCM ciphertext under a key derived from `app.key`, and decrypts values written under any key in `app.previous_keys`, so you can rotate the key without rewriting every row at once. The plaintext has one accessor, `lagoon.Encrypted.Reveal`; printing the value or marshalling it to JSON always gives `[redacted]`: ```go // The application publishes the keys from app.key at boot; a test can // install a key directly. key := []byte("0123456789abcdef0123456789abcdef") if err := lagoon.PublishEncryptionKeys(nil, key, nil); err != nil { fmt.Println(err) return } token := lagoon.NewEncrypted("s3cret") stored, _ := token.Value() // what the column holds fmt.Println(strings.Contains(fmt.Sprint(stored), "s3cret")) var read lagoon.Encrypted if err := read.Scan(stored); err != nil { fmt.Println(err) return } out, _ := json.Marshal(map[string]any{"api_token": read}) fmt.Println(read, string(out)) fmt.Println(read.Reveal()) // Output: // false // [redacted] {"api_token":"[redacted]"} // s3cret ``` The application loads the keys once at boot (`lagoon.OpenFromApp` calls `lagoon.LoadAppKey` and `lagoon.PublishEncryptionKeys`). A missing or short `app.key` stops the application with an error; there is no default key. Generate one with `key:generate`, which prints a key and writes nothing: ```sh ./bin/acme key:generate ``` Keep the key in the environment (`SUMMER_APP__KEY`), not in a committed file. The ciphertext format is not Laravel's. To import rows that the PHP application encrypted, decrypt them once with `lagoon.DecryptLaravelPayload` and the old `APP_KEY`, then save them through `lagoon.Encrypted`. It is meant for a one-off import, never for reading live data. ## Validation `lagoon.Validate` checks a map of input values against Laravel-style rule strings and returns the errors in Laravel's shape, a map from field to messages. It returns `nil` when the input is valid. The second return value is for failures that are not the user's fault, such as an unknown rule or a database error: ```go rules := map[string]string{ "title": "required|max:10", "views": "nullable|integer|max:1000", } input := map[string]any{"title": "", "views": 5000} // A nil translator gives the built-in English messages; unique: rules // need a database handle instead of nil. errs, err := lagoon.Validate(context.Background(), nil, &Post{}, rules, input, nil) if err != nil { fmt.Println(err) } out, _ := json.Marshal(errs) fmt.Println(string(out)) // Output: // {"title":["The title field is required."],"views":["The views may not be greater than 1000."]} ``` The supported rules are `required`, `nullable`, `integer`, `numeric`, `between`, `min`, `max`, `in`, `unique`, `boolean`, `email`, `confirmed`, `different` and `mimes`. Any other rule is an error, so a rule that SummerCMS does not implement cannot be skipped by accident. On a field that is `integer` or `numeric`, `min`, `max` and `between` compare the number; on other fields they compare the length. > [!NOTE] > A failed numeric range check is currently always reported with the `max` message, even when the value is below `min`, and a `min`-only rule then shows an empty limit. Check range errors by field, not by message text. `unique:` runs a query to check that no other row of the table has the value in the field's column, so it needs a database handle; pass the transaction you are writing in. Soft-deleted rows do not count, and when the model you pass has an ID, its own row does not count either, so the same rules work for create and update. Pass a `phrasebook.Translator` as the last argument to get the messages in the request locale from the `lagoon::validate` catalog; with `nil` they are in English. See [Localization](/docs/services/localization.md). A handler validates before it fills and saves the model, as the create example on [Models](/docs/database/models.md) shows. Answer a non-nil error map with status 422 and the body shape the endpoint's existing clients expect. # Attachments Source: /docs/database/attachments.html Attach files to models through WinterCMS-compatible system_files rows, serve originals and thumbnails, and delete blobs only after the transaction commits. WinterCMS attaches files to models with `$attachOne` and `$attachMany`, storing a row per file in `system_files` and the bytes on a disk. The `attach` package of [lagoon](/docs/api/lagoon.md) ports the same table and storage layout, so files uploaded to a WinterCMS site keep working after a data copy. The bytes live in a [gocloud.dev](https://gocloud.dev/howto/blob/) bucket; see [Storage](/docs/services/storage.md) for configuring it. ## The system_files row `attach.File` is the `system_files` row. It links a file to its owner with the WinterCMS polymorphic columns: `AttachmentType` holds the owner's morph name and `AttachmentID` its ID, as a string, and `Field` names the relation, such as `cover` or `gallery`. The `system_files` table is created by the framework migrations, so a plugin does not migrate it. An owner model implements `attach.Owner`. Its `MorphName` returns the PHP class name, so the `attachment_type` values copied from WinterCMS still match: ```go func (Post) MorphName() string { return `Acme\Blog\Models\Post` } ``` ## Storing and serving files The original is stored under WinterCMS's partitioned key: `attach.PartitionDirectory` splits the first nine characters of the random `disk_name` into three directories, and `attach.BlobKey` appends the name. `attach.File.Thumb` returns the public URL of a thumbnail, generating it in the same partition on first use and reusing it afterwards: ```go ctx := context.Background() dir, err := os.MkdirTemp("", "acme-config") if err != nil { fmt.Println(err) return } defer os.RemoveAll(dir) // storage.uploads.bucket_url is a file:// URL in production; mem:// keeps // the example in memory. cfg, err := compass.Open(compass.Options{Dir: dir, Env: "development", Environ: []string{}}) if err != nil { fmt.Println(err) return } _ = cfg.Set("storage.uploads.bucket_url", "mem://") bucket, err := attach.OpenBucket(ctx, cfg) if err != nil { fmt.Println(err) return } defer bucket.Close() // The system_files row of a post's cover image. var owner attach.Owner = Post{ID: 1} f := attach.File{ ID: 7, DiskName: "5f1d0c2e9a7b4c3d8e6f.jpg", FileName: "cover.jpg", ContentType: "image/jpeg", Field: "cover", AttachmentType: owner.MorphName(), AttachmentID: "1", } // The original is stored under its partitioned WinterCMS key. var img bytes.Buffer if err := jpeg.Encode(&img, image.NewRGBA(image.Rect(0, 0, 640, 480)), nil); err != nil { fmt.Println(err) return } if err := bucket.WriteAll(ctx, attach.BlobKey(f.DiskName), img.Bytes(), nil); err != nil { fmt.Println(err) return } fmt.Println(attach.BlobKey(f.DiskName)) // A thumbnail is generated on first use and reused afterwards. url, err := f.Thumb(ctx, bucket, 200, 200, "crop") if err != nil { fmt.Println(err) return } fmt.Println(url) // Output: // 5f1/d0c/2e9/5f1d0c2e9a7b4c3d8e6f.jpg // /storage/uploads/5f1/d0c/2e9/thumb_7_200_200_0_0_crop.jpg ``` URLs start with `storage.uploads.public_path_prefix` (`/storage/uploads` by default). `attach.StaticHandler` serves originals and thumbnails under that prefix; `attach.StaticHandlerPublic` does the same and answers 404 for a row whose `is_public` flag is false. Mount the gated handler when a bucket holds any private file. > [!WARNING] > Serve uploads from a separate origin, or at least never mount the ungated handler on the application's own origin. An uploaded file served with its own content type from the API's origin can run script in that origin. ## Deleting files after commit A rolled-back transaction can restore a row but not the bytes of a deleted blob. Deleting an owner's files is therefore split in two: 1. Inside the transaction that deletes the owner, `attach.DeleteForOwner` deletes the owner's `system_files` rows and passes their blob keys to a callback. The callback only records the keys; it must not delete anything. 2. After the transaction commits, `attach.DeleteKeys` deletes the originals and their thumbnails from the bucket. A soft-deleted owner keeps its rows and files, so only a force delete (`Unscoped().Delete`) runs this. `lagoon.AfterCommit` is a natural place for the second step; see [Transactions](/docs/database/transactions.md). # Transactions Source: /docs/database/transactions.html Run writes in lagoon.Transaction, defer side effects with lagoon.AfterCommit until the commit, and install GORM callbacks from Boot with lagoon.OnDatabase. Laravel's `DB::transaction` runs a closure in a transaction, and `DB::afterCommit` defers work until it commits. [lagoon](/docs/api/lagoon.md) has the same pair, `lagoon.Transaction` and `lagoon.AfterCommit`, and the rest of the framework relies on them: realtime broadcasts, search index updates and blob deletions wait for the commit, so no client hears about a row that was rolled back. ## Running a transaction `lagoon.Transaction` runs a function in a transaction and commits when it returns `nil`. Inside the function, use the `ctx` and `tx` it receives for every write. Work registered with `lagoon.AfterCommit` runs, in registration order, only after the commit succeeds; when the function returns an error, the transaction rolls back and the work is dropped: ```go return lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error { if err := tx.Model(&Post{}).Where("id = ?", id).Update("title", "Published").Error; err != nil { return err } lagoon.AfterCommit(ctx, tx, func(ctx context.Context, db *gorm.DB) { *log = append(*log, fmt.Sprintf("post %d published", id)) // broadcast, index, send mail... }) if fail { return errors.New("rolled back") // the AfterCommit work never runs } return nil }) ``` The callback receives a database handle with an empty statement, so a query it runs never continues from the written model's statement. A panicking callback is logged and does not turn a committed write into an error. `lagoon.AfterCommit` behaves differently depending on where it is called: | Called | The work runs | |--------|---------------| | Inside `lagoon.Transaction` | After the outermost transaction commits; never after a rollback. | | In a GORM callback of a single-statement write (GORM's own implicit transaction) | After GORM commits that write; never when the write fails. | | Inside a plain `gorm.DB.Transaction` or another transaction lagoon did not open | Never. lagoon cannot see whether that transaction commits, so it logs a warning and skips the work. | | Outside any transaction | Immediately. | The third row is deliberate: running the work early could announce a write that later rolls back. When code in a transaction needs after-commit work, open the transaction with `lagoon.Transaction`. ## Nested transactions A `lagoon.Transaction` inside another becomes a savepoint. Its after-commit work joins the outer transaction's only when its own function succeeds, so work dropped with a failed savepoint never runs: ```go return lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error { lagoon.AfterCommit(ctx, tx, func(context.Context, *gorm.DB) { *log = append(*log, "outer") }) // A nested Transaction is a savepoint. Pass it the outer tx: given // the root db handle it returns an error instead. _ = lagoon.Transaction(ctx, tx, func(ctx context.Context, tx *gorm.DB) error { lagoon.AfterCommit(ctx, tx, func(context.Context, *gorm.DB) { *log = append(*log, "dropped") }) return errors.New("savepoint rolled back") }) return lagoon.Transaction(ctx, tx, func(ctx context.Context, tx *gorm.DB) error { lagoon.AfterCommit(ctx, tx, func(context.Context, *gorm.DB) { *log = append(*log, "inner") }) return nil }) }) ``` Pass the nested call the outer transaction's `tx`. Given the root database handle instead, the nested `lagoon.Transaction` returns an error without running its function. Otherwise it would open a second, independent transaction whose after-commit work would wait for the outer one. ## Callbacks registered at boot A hook that calls a service, such as a broadcast or a job dispatch, is a GORM callback rather than a model method (see [Models](/docs/database/models.md)). A plugin registers it from `Boot`, but `Boot` runs before the `serve` command opens the database. `lagoon.OnDatabase` bridges the gap: it runs your function as soon as the database is published, immediately when it already is. Register the callback before GORM's `gorm:commit_or_rollback_transaction` step and defer its side effect with `lagoon.AfterCommit`: ```go return lagoon.OnDatabase(app, func(_ *sql.DB, gdb *gorm.DB) error { return gdb.Callback().Create().After("gorm:create").Before("gorm:commit_or_rollback_transaction").Register("acme:post_created", func(db *gorm.DB) { post, ok := db.Statement.Dest.(*Post) if db.Error != nil || !ok { return } lagoon.AfterCommit(db.Statement.Context, db, func(ctx context.Context, db *gorm.DB) { *log = append(*log, "created "+post.Slug) // runs only once the insert is committed }) }) }) ``` > [!WARNING] > Register such a callback with a `Before("gorm:commit_or_rollback_transaction")` constraint, as above. lagoon runs a single-statement write's after-commit work from its own callback right after that commit step; a callback that GORM sorts after it buffers work that is never run, without an error. ## Boot-time work that needs the database `lagoon.OnDatabase` is also the place for anything else a plugin must do with the database handle at start-up, such as registering a join table with `lagoon.RegisterJoinTable`. The error of a queued function is returned by `lagoon.Publish`, so a failing callback stops the start-up. # Configuration Source: /docs/services/configuration.html Read layered configuration with compass, from plugin defaults through per-environment files and SUMMER_ variables to runtime overrides saved to disk. WinterCMS reads configuration with `Config::get('app.name')` from `config/*.php`, per-environment directories and `.env`. SummerCMS reads it from a `compass.Config` built by [compass](/docs/api/compass.md), with the same dot paths. [Setup, Configuration](/docs/setup/configuration.md) lists the keys an application sets; this page covers how the layers merge and how code reads and changes them. ## Layers `compass.Config` merges its sources in a fixed order. Each layer overrides the ones before it: 1. Plugin defaults, merged with `compass.Config.MergePlugin` when the plugin is activated. A plugin's `config/config.yaml` becomes `.`, so `acme.blog.posts_per_page` is the WinterCMS `acme.blog::posts_per_page`. Any other `config/.yaml` becomes `..`. 2. `config/*.yaml`. Each file is a section named after the file, so `config/app.yaml` provides `app.*`. 3. `config/env//*.yaml`, the per-environment sections. 4. `SUMMER_` environment variables and the `.env` file next to `config/`. `SUMMER_MAIL__DRIVER` sets `mail.driver`: the prefix is removed, `__` separates the path segments and the name is lower-cased. A `.env` value applies only when the real environment does not set the same variable. 5. `config/env//overrides.yaml`, written by `compass.Config.Persist`. 6. Values set in memory with `compass.Config.Set`. The environment comes from `SUMMER_ENV` and defaults to `production`. Its name may contain only letters, digits, `-` and `_`. ## Reading values The typed getters `compass.Config.String`, `compass.Config.Int` and `compass.Config.Bool` return the zero value for a missing key. Use `compass.Config.Lookup` or `compass.Config.Has` when a missing key must be told apart from a zero value, and `compass.Config.LoadSection` to read a whole section into a struct with `koanf` tags: ```go dir, err := os.MkdirTemp("", "acme-config") if err != nil { fmt.Println(err) return } defer os.RemoveAll(dir) if err := writeConfig(dir); err != nil { fmt.Println(err) return } // Environ stands in for the process environment (nil reads os.Environ). cfg, err := compass.Open(compass.Options{ Dir: dir, Env: "development", Environ: []string{"SUMMER_MAIL__DRIVER=smtp"}, }) if err != nil { fmt.Println(err) return } // A plugin's embedded config/config.yaml becomes its defaults. plugin := fstest.MapFS{"config/config.yaml": {Data: []byte("posts_per_page: 10\n")}} if err := cfg.MergePlugin("acme.blog", plugin); err != nil { fmt.Println(err) return } fmt.Println(cfg.String("app.name"), cfg.Bool("app.debug"), cfg.Int("acme.blog.posts_per_page")) var mail mailSettings if err := cfg.LoadSection("mail", &mail); err != nil { fmt.Println(err) return } fmt.Println(mail.Driver, mail.From) _, found := cfg.Lookup("app.timezone") fmt.Println(cfg.Environment(), found) // A runtime override, saved to env/development/overrides.yaml. if err := cfg.Set("acme.blog.posts_per_page", 25); err != nil { fmt.Println(err) return } if err := cfg.Persist(); err != nil { fmt.Println(err) return } saved, _ := os.ReadFile(filepath.Join(dir, "env", "development", "overrides.yaml")) fmt.Print(string(saved)) // Output: // Acme true 10 // smtp blog@example.com // development false // acme: // blog: // posts_per_page: 25 ``` The application opens its configuration once with `compass.Load("config")` in the generated `main` and hands it to the `backpack.App`. Plugins read it through `app.Config` and never open their own. ## Changing values at runtime `compass.Config.Set` changes a value in memory. `compass.Config.Persist` saves every value set at runtime to `config/env//overrides.yaml`, keeping the keys already saved there. It replaces the file atomically, creates it readable only by its owner and refuses any path outside the config directory. `compass.Config.Reload` rereads every source and discards values that were set but not persisted. Values that admins edit in the backend are not configuration: settings pages store them in a database row; see [Settings](/docs/backend/settings.md). > [!WARNING] > `overrides.yaml` is written by the running application. Keep it out of version control, and keep secrets in environment variables rather than in values the application persists. # Events Source: /docs/services/events.html Listen for and fire typed events on the application bus with festival, with priorities, collected results and a halting fire that stops when handled. `Event::listen` and `Event::fire` are how WinterCMS plugins extend each other. SummerCMS keeps the pattern with [festival](/docs/api/festival.md): each application has one bus, `backpack.App.Events`, and plugins listen from their `Boot` step for events that other plugins fire. [Extending plugins](/docs/plugins/extending.md) shows where events fit among the other extension points; this page covers the bus itself. ## Typed events An event is a Go type, not a string. A listener is a function that takes a context and the event, and the bus routes by type, so a listener never receives a payload of the wrong shape and a mismatch does not compile. Name events after what happened, and keep them in the package of the plugin that fires them so listeners can import the type. `festival.Bus.Listen` registers a listener at priority 0 and `festival.Bus.ListenPriority` at a given priority. Higher priorities run first, and listeners with the same priority run in registration order. The first argument is the ID of the plugin that owns the listener: ```go bus := festival.New() // in a plugin, use app.Events // acme.search and acme.notify extend acme.blog from their Boot steps. bus.Listen("acme.search", func(ctx context.Context, e PostPublished) error { fmt.Println("index", e.Title) return nil }) bus.ListenPriority("acme.notify", 10, func(ctx context.Context, e PostPublished) error { fmt.Println("notify subscribers of", e.Title) return nil }) // acme.blog fires the event; higher priorities run first. if err := bus.Fire(context.Background(), PostPublished{Title: "Hello"}); err != nil { fmt.Println(err) } // Output: // notify subscribers of Hello // index Hello ``` `festival.Bus.Fire` runs every listener, even after one fails, and returns the failures joined with `errors.Join`. A listener that panics is recovered and reported as an error that names its plugin, so one faulty plugin cannot stop the others. All three dispatch methods run the listeners on the caller's goroutine, before they return. For work that should not delay the request, a listener dispatches a job; see [Queued jobs](/docs/services/jobs.md). ## Collecting contributions WinterCMS events often gather something from their listeners, such as extra form fields or menu items. `festival.Bus.Collect` runs every listener and, after each one, merges the map the event returns from `festival.Collectable.Collected`. A later listener wins when two set the same key. Use a pointer event so listeners can write to it: ```go bus := festival.New() bus.Listen("acme.seo", func(ctx context.Context, e *PostFormExtended) error { e.fields = map[string]any{"meta_title": "text"} return nil }) bus.Listen("acme.gallery", func(ctx context.Context, e *PostFormExtended) error { e.fields = map[string]any{"cover": "fileupload"} return nil }) fields, err := bus.Collect(context.Background(), &PostFormExtended{}) fmt.Println(fields, err) // Output: map[cover:fileupload meta_title:text] ``` `festival.Bus.Collect` returns the payload gathered so far together with the joined errors, so one failing listener does not lose the others' contributions. ## Stopping at the first handler The WinterCMS halting fire stops at the first listener that returns a result. `festival.Bus.UntilHandled` stops as soon as the event's `festival.Handleable.IsHandled` reports true, or at the first error, and returns whether the event was handled: ```go bus := festival.New() bus.ListenPriority("acme.pages", 10, func(ctx context.Context, e *SlugResolving) error { if e.Slug == "about" { e.Found = "page" } return nil }) bus.Listen("acme.blog", func(ctx context.Context, e *SlugResolving) error { fmt.Println("acme.blog asked for", e.Slug) e.Found = "post" return nil }) for _, slug := range []string{"about", "hello-world"} { e := &SlugResolving{Slug: slug} handled, err := bus.UntilHandled(context.Background(), e) fmt.Println(slug, handled, e.Found, err) } // Output: // about true page // acme.blog asked for hello-world // hello-world true post ``` Here `acme.pages` listens at a higher priority, so it gets the first chance to claim a slug, and `acme.blog` is asked only when no page matched. ## Events and transactions Listeners run where the event is fired, inside any transaction the caller has open. A listener that writes to the database joins that transaction when it uses the transaction handle the event carries. A listener with a side effect outside the database, such as a mail or a broadcast, should defer it with `lagoon.AfterCommit`, so it does not announce a write that rolls back. See [Transactions](/docs/database/transactions.md). # Routing Source: /docs/services/routing.html Declare a plugin's HTTP routes with groups, auth groups, path constraints and named middleware through pact.HasRoutes, and write JSON responses with wire. A WinterCMS plugin declares its routes in `routes.php` with `Route::group`, `->middleware()` and `->where()`. A SummerCMS plugin implements `pact.HasRoutes`: its `Routes` method receives a `pact.Router` with the same builder shape. [surf](/docs/api/surf.md) collects every plugin's routes into one standard library `http.ServeMux`, and checks all of them when the application starts, so a duplicate route, an unknown middleware name or a malformed throttle stops the start-up instead of failing on the first request. Handlers are ordinary `http.HandlerFunc` values. There are no controllers to extend and no request objects to learn. ## Declaring routes This plugin declares public routes, an auth group, path constraints and per-route middleware: ```go // Routes is the Go form of the plugin's routes.php. func (p *BlogPlugin) Routes(r pact.Router) error { r.Group("/api/blog", surf.Use("throttle:60,1"), func(g pact.Router) { g.Get("/posts/{id}", showPost) g.Where("id", `[0-9]+`) g.Get("/posts/{status}/list", listPosts) g.WhereIn("status", "draft", "published") // An auth group: every route inside needs a signed-in user. g.Group("", surf.Use("acme.auth"), func(auth pact.Router) { auth.Get("/me", showMe) auth.Post("/posts/{id}/comments", addComment, "throttle:blog.comments", "body.limit:65536") }) }) return nil } ``` - `pact.Router.Group` adds a path prefix and a middleware list to the routes declared inside it; groups nest. `surf.Use` builds the list. - `pact.Router.Get`, `pact.Router.Post`, `pact.Router.Put`, `pact.Router.Patch` and `pact.Router.Delete` take a path in Go's pattern syntax (`/posts/{id}`) and optional middleware names for that route alone. - `pact.Router.Where` restricts a path parameter of the route declared just before it to a regular expression matched against the whole segment, and `pact.Router.WhereIn` to a list of values. A request that fails a constraint gets a 404. In a handler, `r.PathValue("status")` reads a parameter, and `surf.IntParam` reads one as a positive integer: ```go func showPost(w http.ResponseWriter, r *http.Request) { id, ok := surf.IntParam(r, "id") if !ok { http.NotFound(w, r) return } wire.WriteJSON(w, http.StatusOK, map[string]any{"id": id}) } ``` ## Auth groups An auth group is a group whose middleware list names a guard. The plugin above turns a [bouncer](/docs/api/bouncer.md) JWT guard into named middleware and returns it from `pact.HasMiddleware`: ```go // Middlewares registers the plugin's named middleware: here, a JWT guard // that answers 401 when the request has no valid token. func (p *BlogPlugin) Middlewares() map[string]pact.Middleware { guards := bouncer.NewRegistry() guard := bouncer.NewJWTGuard(secret, users{}, bouncer.NewMemoryBlacklist()) if err := guards.Register(p.ID(), "acme.auth", guard); err != nil { panic(err) } auth, err := guards.Middleware("acme.auth") if err != nil { panic(err) } return map[string]pact.Middleware{"acme.auth": auth} } ``` Every route in the group then requires a valid token, and handlers read the signed-in user with `bouncer.User`. The guard answers 401 with a JSON body when the token is missing or invalid. See [Authentication](/docs/services/authentication.md) for guards and tokens. The admin API uses the built-in `backend` middleware name, which the framework registers when the admin is enabled. ## Middleware Named middleware is any `func(http.Handler) http.Handler` a plugin returns from `pact.HasMiddleware`. A plugin that needs a parameter, used as `name:param`, returns a factory from `pact.HasMiddlewareFactories`. Middleware names are global, so prefix them with the plugin: `acme.auth`, `blog.no-store`. A duplicate name fails the start-up. The framework registers these names: | Name | Does | |------|------| | `throttle:` or `throttle:,` | Rate limiting; see [Rate limiting](/docs/services/rate-limiting.md). | | `body.limit:` | Replaces the default request body limit for the route. | | `locale.from-principal` | Switches the request locale to the signed-in user's preferred locale. | | `backend` | The admin guard, when the admin is enabled. | Every route also gets, around its own middleware, JSON panic recovery, the request locale from `Accept-Language`, the body limit from `http.body_limits.default_bytes`, and CORS headers when its path matches `http.cors.paths`. The order is described in [Request lifecycle](/docs/architecture/request-lifecycle.md). `pact.Router.GroupRaw` declares a raw group for routes that must not be wrapped in the house JSON middleware, such as webhooks, file streams or the OAuth endpoints: the default body limit is skipped, and a panic returns a bare 500. ## Responses Write JSON with `wire.WriteJSON`. It produces what PHP's `json_encode` produces: HTML characters are not escaped and there is no trailing newline. `wire.Time` marshals a timestamp as Carbon does (`+00:00`, never `Z`), `wire.TriBool` is a nullable boolean, and `wire.Slice` turns a nil slice into `[]`: ```go var tags []string // nil: the post has no tags warsaw := time.FixedZone("CEST", 2*60*60) body := postJSON{ ID: 1, Title: "Tips & ", Tags: wire.Slice(tags), Featured: wire.TriBool{}, Pinned: wire.TriBool{Value: true, Valid: true}, PublishedAt: wire.Time{Time: time.Date(2026, 9, 30, 14, 5, 0, 0, warsaw)}, } rec := httptest.NewRecorder() wire.WriteJSON(rec, http.StatusOK, map[string]any{"data": body}) fmt.Println(rec.Code, rec.Header().Get("Content-Type")) fmt.Printf("%s|\n", rec.Body.String()) rec = httptest.NewRecorder() wire.WriteOpaque500(rec) fmt.Println(rec.Code, rec.Body.String()) // Output: // 200 application/json // {"data":{"id":1,"title":"Tips & ","tags":[],"featured":null,"pinned":true,"published_at":"2026-09-30T12:05:00+00:00"}}| // 500 {"error":true,"message":"Internal server error"} ``` `wire.WriteOpaque500` writes the fixed 500 body that panic recovery also uses; it reveals nothing about the failure. ## Testing and listing routes `surf.Assemble` builds the complete handler from the application and its plugins, so a test can drive it with `net/http/httptest`: ```go // The application passes its config; http.body_limits is required there. app := backpack.New(nil) plugin := &BlogPlugin{} if err := plugin.Register(app); err != nil { // the runtime calls Register fmt.Println(err) return } h, err := surf.Assemble(app, []party.Plugin{plugin}) if err != nil { fmt.Println(err) return } token, _, _ := bouncer.Mint(secret, "42", "http://127.0.0.1:8080/api/login", time.Hour) do := func(method, path string, auth bool) { req := httptest.NewRequest(method, path, strings.NewReader("{}")) if auth { req.Header.Set("Authorization", "Bearer "+token) } rec := httptest.NewRecorder() h.ServeHTTP(rec, req) fmt.Println(method, path, rec.Code, strings.TrimSpace(rec.Body.String())) } do("GET", "/api/blog/posts/7", false) do("GET", "/api/blog/posts/seven", false) do("GET", "/api/blog/posts/draft/list", false) do("GET", "/api/blog/posts/deleted/list", false) do("GET", "/api/blog/me", false) do("GET", "/api/blog/me", true) do("POST", "/api/blog/posts/7/comments", true) do("POST", "/api/blog/posts/7/comments", true) // Output: // GET /api/blog/posts/7 200 {"id":7} // GET /api/blog/posts/seven 404 404 page not found // GET /api/blog/posts/draft/list 200 {"data":[],"status":"draft"} // GET /api/blog/posts/deleted/list 404 404 page not found // GET /api/blog/me 401 {"error":true,"message":"Token not provided"} // GET /api/blog/me 200 {"id":42} // POST /api/blog/posts/7/comments 201 {"created":true} // POST /api/blog/posts/7/comments 429 {"message":"Too Many Attempts."} ``` `route:list` builds the router the way `serve` does, without opening the database or listening, and prints every route with its plugin and middleware: ```sh ./bin/acme route:list ``` `surf.BuildRouter` returns the same information to Go code through `surf.Router.Routes`: ```go r, err := surf.BuildRouter(backpack.New(nil), []party.Plugin{&BlogPlugin{}}) if err != nil { fmt.Println(err) return } for _, rt := range r.Routes() { fmt.Println(rt.Method, rt.Pattern, rt.PluginID, rt.Middleware) } // Output: // GET /api/blog/posts/{id} acme.blog [throttle:60,1] // GET /api/blog/posts/{status}/list acme.blog [throttle:60,1] // GET /api/blog/me acme.blog [throttle:60,1 acme.auth] // POST /api/blog/posts/{id}/comments acme.blog [throttle:60,1 acme.auth throttle:blog.comments body.limit:65536] ``` ## CORS CORS is configured with the keys of Laravel's `config/cors.php`, under `http.cors`, and applies only to paths that match `http.cors.paths`. With no `http.cors` section, no CORS headers are sent: ```yaml cors: paths: ["api/*"] allowed_origins: ["https://blog.example.com"] allowed_methods: ["*"] allowed_headers: ["*"] supports_credentials: true ``` This fragment belongs in `config/http.yaml`. List the frontend's exact origin; `*` is for public, credential-free APIs only. # Rate limiting Source: /docs/services/rate-limiting.html Throttle routes with inline limits or named buckets, key them by user or client IP, and trust X-Forwarded-For only from configured proxies. Laravel limits requests with the `throttle` middleware and named limiters from `RateLimiter::for`. SummerCMS has both through [surf](/docs/api/surf.md): a `throttle` middleware that takes an inline limit or the name of a bucket a plugin declares. ## Inline limits `throttle:,` allows `max` requests per window of `minutes` minutes. The counter is kept per signed-in user, or per client IP for guests. The plugin on [Routing](/docs/services/routing.md) puts `throttle:60,1` on its whole `/api/blog` group, so each client may make 60 requests a minute to it. ## Named buckets A bucket gives a limit its own key, such as the client IP plus the route, or a token ID. A plugin declares buckets by implementing `surf.BucketProvider`, and routes name them as `throttle:`: ```go // Buckets declares a named rate limit, used as throttle:blog.comments. func (p *BlogPlugin) Buckets() map[string]surf.Bucket { return map[string]surf.Bucket{ "blog.comments": { Max: 1, Decay: time.Minute, Key: func(r *http.Request) string { return "comments|" + surf.ClientIP(r, p.trusted) }, }, } } ``` Each `surf.Bucket` has the maximum number of requests, the window length (`Decay`) and a `Key` function that builds the counter key from the request. Prefix the key with the bucket's purpose, as above, so two buckets never share a counter. A route may name several throttles; each counts separately. A throttle naming a bucket that no plugin declares fails the start-up. A request over the limit gets a 429 response with the body `{"message":"Too Many Attempts."}` and a `Retry-After` header. Every throttled response carries `X-RateLimit-Limit` and `X-RateLimit-Remaining`, and a rejected one also `X-RateLimit-Reset`. The limiter is a fixed window: the counter resets when the window ends, as Laravel's cache limiter does. Counters live in the process (`surf.MemoryStore`, behind the `surf.Store` interface), so each application instance counts on its own. Behind a load balancer with several instances, the effective limit is the configured limit times the number of instances. ## Client IP and trusted proxies `surf.ClientIP` is the one place the client IP comes from. It uses the connection's remote address, and reads `X-Forwarded-For` only when that address is inside a range listed in `http.trusted_proxies`. It then takes the rightmost address that is not itself a trusted proxy, so a client cannot choose its own IP by sending the header: ```go cfg, err := compass.Open(compass.Options{Dir: "config", Env: "development", Environ: []string{}}) if err != nil { fmt.Println(err) return } _ = cfg.Set("http.trusted_proxies", []string{"10.0.0.0/8"}) trusted := surf.TrustedProxies(cfg) // Through the load balancer at 10.0.0.5: the forwarded client is used. viaProxy := httptest.NewRequest("GET", "/api/blog/posts", nil) viaProxy.RemoteAddr = "10.0.0.5:4711" viaProxy.Header.Set("X-Forwarded-For", "198.51.100.23, 10.0.0.9") fmt.Println(surf.ClientIP(viaProxy, trusted)) // Straight from the internet: a forged header is ignored. direct := httptest.NewRequest("GET", "/api/blog/posts", nil) direct.RemoteAddr = "203.0.113.7:5000" direct.Header.Set("X-Forwarded-For", "127.0.0.1") fmt.Println(surf.ClientIP(direct, trusted)) // Output: // 198.51.100.23 // 203.0.113.7 ``` List only the proxies you run, in `config/http.yaml`: ```yaml trusted_proxies: ["10.0.0.0/8"] ``` With the list empty (the default), the header is ignored and every request behind a proxy has the proxy's IP. A malformed entry is skipped. A bucket's `Key` function should use the same trusted list, as the plugin above does by reading `surf.TrustedProxies` in `Register`. # Authentication Source: /docs/services/authentication.html Mint and verify JWTs with bouncer, turn guards into route middleware, revoke tokens through a jti blacklist and hash passwords with bcrypt. WinterCMS reads the current user through the `Auth` and `BackendAuth` facades, and API plugins add a JWT layer on top. SummerCMS has no facades: [bouncer](/docs/api/bouncer.md) turns a request into a `bouncer.Principal` through a guard, stores it on the request context, and issues and checks the tokens. The frontend user model and its login endpoints belong to the application's user plugin; bouncer supplies the building blocks. ## Tokens bouncer issues HS256 JSON Web Tokens for two audiences: frontend users (`bouncer.AudienceUser`) and admins (`bouncer.AudienceBackend`). `bouncer.Mint` signs a frontend token for a subject, the user ID as a string, and returns the token and its random `jti`. `bouncer.MintAudience` signs one for any audience. `bouncer.VerifyClaims` checks the signature with HS256 pinned, requires `exp` and `sub`, and returns the claims; `bouncer.Verify` returns only the subject. Both accept frontend tokens, including older tokens without an audience claim. `bouncer.VerifyClaimsAudience` requires the audience you name, so a frontend token never passes an admin check and the other way round: ```go const issuer = "http://127.0.0.1:8080/api/login" token, _, err := bouncer.Mint(testSecret, "42", issuer, time.Hour) if err != nil { fmt.Println(err) return } sub, iat, exp, _, err := bouncer.VerifyClaims(token, testSecret) fmt.Println(sub, exp.Sub(iat), err) // A frontend token never passes a backend check, and a wrong secret fails. _, _, _, _, err = bouncer.VerifyClaimsAudience(token, testSecret, bouncer.AudienceBackend) fmt.Println(err != nil) _, err = bouncer.Verify(token, "another-secret-with-at-least-32-bytes") fmt.Println(err != nil) // Refresh reissues the token and blacklists the old jti after the grace. bl := bouncer.NewMemoryBlacklist() fresh, err := bouncer.Refresh(testSecret, token, 14*24*time.Hour, bl, 0, issuer) fmt.Println(fresh != token, err) _, _, _, jti, _ := bouncer.VerifyClaims(token, testSecret) revoked, _ := bl.IsBlacklisted(context.Background(), jti) fmt.Println(revoked) // Output: // 42 1h0m0s // true // true // true // true ``` The secret in these examples is a test value. In an application, read the signing secret from configuration set through an environment variable, use at least 32 random bytes and never commit it. ## Refreshing and revoking `bouncer.Refresh` reissues a token while its `iat` is inside the refresh window, even when it has expired, and blacklists the old `jti` after a grace period, so requests already in flight with the old token still succeed. `bouncer.RefreshAudienceFor` also reloads the user and refuses one who was deleted, or whose tokens were issued before `bouncer.Principal.TokensValidAfter`, with `bouncer.ErrSubjectRejected`. The rules match the PHP jwt-auth library, so tokens issued by a WinterCMS application keep working after a port. Revoked token IDs are kept in a `bouncer.BlacklistStore`: - `bouncer.NewMemoryBlacklist` keeps them in the process, for tests. - `bouncer.NewPostgresBlacklist` keeps them in a table you name, with `jti`, `expires_at` and `valid_until` columns. It rejects table names that are not plain identifiers. Logging out is blacklisting the token's `jti`. Setting a user's `TokensValidAfter` to now revokes all their tokens at once, for example after a password change. ## Guards and middleware A guard implements `bouncer.Guard`: it turns a request into a principal or an error. `bouncer.NewJWTGuard` is the frontend guard. It reads the bearer token, then any cookie names you give it, verifies the token, checks the blacklist and the user's cutoff, and loads the user through your `bouncer.UserProvider`. On failure it answers 401 with `{"error":true,"message":...}`. `bouncer.NewBackendJWTGuard` is the same for the admin audience. Register guards in a `bouncer.Registry` under a name, and turn one into middleware with `bouncer.Registry.Middleware`. The middleware stores the principal on the context, where handlers read it with `bouncer.User`: ```go guards := bouncer.NewRegistry() guard := bouncer.NewJWTGuard(testSecret, users{}, bouncer.NewMemoryBlacklist(), "token") if err := guards.Register("acme.blog", "acme.auth", guard); err != nil { fmt.Println(err) return } auth, err := guards.Middleware("acme.auth") if err != nil { fmt.Println(err) return } me := auth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { user, _ := bouncer.User(r.Context()) fmt.Fprintf(w, "user %d, locale %s", user.ID, user.PreferredLocale) })) token, _, _ := bouncer.Mint(testSecret, "42", "http://127.0.0.1:8080/api/login", time.Hour) for _, set := range []func(*http.Request){ func(r *http.Request) {}, func(r *http.Request) { r.Header.Set("Authorization", "Bearer "+token) }, func(r *http.Request) { r.AddCookie(&http.Cookie{Name: "token", Value: token}) }, } { req := httptest.NewRequest("GET", "/api/me", nil) set(req) rec := httptest.NewRecorder() me.ServeHTTP(rec, req) fmt.Println(rec.Code, strings.TrimSpace(rec.Body.String())) } // Output: // 401 {"error":true,"message":"Token not provided"} // 200 user 42, locale pl // 200 user 42, locale pl ``` To protect routes, return that middleware from `pact.HasMiddleware` under a name and put the name on a route group. [Routing](/docs/services/routing.md) shows the complete auth group. A guard that does not implement `bouncer.UnauthorizedWriter` lets an unauthenticated request through without a principal, for routes that behave differently for guests. A guard that resolves more than a user, such as an API token record, implements `bouncer.CredentialGuard`; the middleware stores that record too, and handlers read it with `bouncer.Credential`. ## Passwords `bouncer.HashPassword` hashes with bcrypt at the cost you give it, `bouncer.CheckPassword` compares in constant time, and `bouncer.NeedsRehash` reports a hash made below your configured cost. WinterCMS stores bcrypt hashes too, so existing passwords keep working: ```go hash, err := bouncer.HashPassword(10, "correct horse battery staple") if err != nil { fmt.Println(err) return } fmt.Println(bouncer.CheckPassword(hash, "correct horse battery staple")) fmt.Println(bouncer.CheckPassword(hash, "wrong")) // After raising the configured cost, rehash on the next successful login. fmt.Println(bouncer.NeedsRehash(hash, 12)) // Output: // true // false // true ``` Rehash a password on the next successful login when `bouncer.NeedsRehash` reports true. Admin sign-in, admin permissions and the admin user commands are covered in [Users and permissions](/docs/backend/users-and-permissions.md). # OAuth server Source: /docs/services/oauth-server.html Let MCP clients act for your users with the wristband OAuth server, covering metadata, dynamic client registration, PKCE, consent and refresh token rotation. [wristband](/docs/api/wristband.md) is the protocol side of an OAuth 2 authorization server, built for MCP clients such as AI assistants and connectors that act on behalf of the application's users. WinterCMS core has no counterpart. wristband provides the HTTP handlers and the consent operations; the application provides the storage, the access tokens it already uses for its API, and the consent screen. ## What it implements - The RFC 8414 metadata document, advertising the endpoints, the `authorization_code` and `refresh_token` grants, S256 PKCE and the RFC 9207 `iss` response parameter. - RFC 7591 dynamic client registration: public clients (`none`) and confidential ones (`client_secret_post`, `client_secret_basic`), redirect URI validation, a cap on unrevoked clients and a sweep of old clients that never got consent. - The authorization endpoint, which validates the client and its exact registered redirect URI before it redirects anywhere, then checks PKCE, the client's scopes and the RFC 8707 `resource` value, stores a pending request and sends the browser to the application's consent page. - The token endpoint: code exchange with PKCE verification, then an access token from the application and a rotating refresh token. Reusing a spent refresh token revokes its whole lineage and the access tokens issued from it. Client secrets, codes and refresh tokens are random strings stored only as SHA-256 hashes and compared in constant time. ## Configuring the server wristband reads no configuration keys. The application builds a `wristband.Options` value from `wristband.DefaultOptions` and sets at least `wristband.Options.Issuer`, its own URL without a trailing slash, and `wristband.Options.Resource`, the URL of the protected resource its tokens are for: ```go // newServer builds the authorization server of an application served at // https://blog.example.com. The application sets Issuer and Resource for // its own deployment; the defaults cover everything else. func newServer() *wristband.Server { opts := wristband.DefaultOptions() opts.Issuer = "https://blog.example.com" opts.Resource = "https://blog.example.com/mcp" opts.ScopesSupported = []string{"read", "write", "offline_access"} return wristband.NewServer(opts) } ``` > [!WARNING] > Always set `wristband.Options.Resource` for your deployment. Do not rely on the value `wristband.DefaultOptions` returns. The defaults cover the rest: pending requests and codes live 10 minutes, access tokens 1 hour and refresh tokens 30 days, at most 200 unrevoked clients may register, and registration bodies are capped at 64 KiB. The metadata document serves the configured values: ```go srv := newServer() // srv.SetBackend(backend) attaches the application's stores; the // metadata document does not need them. rec := httptest.NewRecorder() srv.Metadata(rec, httptest.NewRequest("GET", "/.well-known/oauth-authorization-server", nil)) var doc map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &doc); err != nil { fmt.Println(err) return } for _, key := range []string{ "issuer", "authorization_endpoint", "token_endpoint", "registration_endpoint", "scopes_supported", "grant_types_supported", "code_challenge_methods_supported", } { fmt.Println(key, doc[key]) } // Output: // issuer https://blog.example.com // authorization_endpoint https://blog.example.com/oauth/mcp/authorize // token_endpoint https://blog.example.com/oauth/mcp/token // registration_endpoint https://blog.example.com/oauth/mcp/register // scopes_supported [read write offline_access] // grant_types_supported [authorization_code refresh_token] // code_challenge_methods_supported [S256] ``` ## Storage The server has no database code. The application attaches its storage with `wristband.Server.SetBackend`; until it does, handlers that need storage answer 500. A `wristband.Backend` runs a function inside one database transaction with a `wristband.Tx`, which bundles the stores and the token issuer: | Interface | Stores | |-----------|--------| | `wristband.ClientStore` | Registered clients (`wristband.ClientRecord`): lookup, capped create, the sweep and the consent stamp. | | `wristband.AuthCodeStore` | Pending requests and issued codes (`wristband.AuthCodeRecord`). | | `wristband.RefreshTokenStore` | Refresh token lineages (`wristband.RefreshTokenRecord`), including rotation and revocation. | | `wristband.AccessTokenIssuer` | Mints and revokes the application's own API tokens. | Registration, code exchange and refresh each run in one transaction through this interface, so the writes of each step commit or roll back together: a code is never marked used without its tokens, and a refresh token is never spent without its replacement. ## Routes Mount the handlers in a raw group, because the OAuth endpoints define their own response formats and must not be wrapped in the JSON envelope middleware (see [Routing](/docs/services/routing.md)): | Route | Handler | |-------|---------| | `GET /.well-known/oauth-authorization-server` | `wristband.Server.Metadata` | | `GET /oauth/mcp/authorize` | `wristband.Server.Authorize` | | `POST /oauth/mcp/token` | `wristband.Server.Token` | | `POST /oauth/mcp/register` | `wristband.Server.Register` | The metadata document advertises these paths under the issuer, so mount them at exactly these paths. ## Consent The authorization endpoint sends the browser to `/connect?request=`. That page belongs to the application: it signs the user in, shows what the client asks for and posts the decision to the application's own consent handler, which calls: - `wristband.Server.PendingRequest` to read what to show, as a `wristband.PendingRequestView`; - `wristband.Server.IssueCode` to grant, which returns the redirect URL carrying the code, `iss` and `state`; - `wristband.Server.DenyPending` to refuse, which returns the `access_denied` redirect URL. `wristband.Server.IssueCode` stores exactly the scopes it is given. The consent handler must pass only scopes that the pending request asked for, the user accepted and the application can grant. A missing, foreign, used or expired request is `wristband.ErrPendingNotFound` in every case, so the handler cannot tell another user's request ID from an invalid one. `wristband.Server.Revoke` disconnects an app: it revokes an access token and the refresh lineage behind it. ## Clients created outside registration Operator tooling that creates clients directly uses the same rules as registration. `wristband.RejectRedirectURI` accepts `https://` URIs and loopback `http://` URIs only: ```go for _, uri := range []string{ "https://client.example.org/callback", "http://127.0.0.1:33418/callback", "http://client.example.org/callback", } { if reason := wristband.RejectRedirectURI(uri); reason != "" { fmt.Println("rejected:", reason) continue } fmt.Println("accepted:", uri) } // Output: // accepted: https://client.example.org/callback // accepted: http://127.0.0.1:33418/callback // rejected: Redirect URI must be https:// or loopback http://127.0.0.1 / http://localhost: http://client.example.org/callback ``` `wristband.IssueClientCredentials` generates the client ID and, for a confidential client, a secret that is returned once and its hash, which is what you store: ```go // A confidential client gets a secret, shown once; store only the hash. id, secret, hash, err := wristband.IssueClientCredentials("client_secret_post") fmt.Println(id != "", secret != "", hash != nil && *hash != secret, err) // A public client (PKCE only) gets no secret. _, secret, hash, err = wristband.IssueClientCredentials("none") fmt.Println(secret == "", hash == nil, err) // Output: // true true true // true true ``` # Mail Source: /docs/services/mail.html Ship WinterCMS-style mail templates in a plugin, send them through postcard, and deliver them with the memory, log or SMTP driver. WinterCMS plugins ship mail templates in `views/mail` and send them with `Mail::send`. SummerCMS keeps the file format and the dotted template names; [postcard](/docs/api/postcard.md) loads them at boot and sends them through a configured driver. ## Templates and layouts A plugin implements `pact.HasMailTemplates`: it returns its embedded `views/mail` files, the template names it ships and short aliases for its layouts. A template named `acme.blog::mail.welcome` lives in `views/mail/welcome.htm` (dots in the name become directories), and a plugin may only register names in its own namespace. A missing file, a duplicate name or an unknown layout alias fails the start-up. The file format is WinterCMS's: an INI header with the `subject`, the `layout` alias and a `description`, a `==` line, then a Markdown body with Go template variables such as `{{ .name }}`. A layout has a header, a text wrapper and an HTML wrapper, separated by `==` lines, each with `{{ .Content }}` where the message goes. A neutral `default` layout is built in. Each message gets an HTML part, rendered from the Markdown, and a plain-text part. `mail.css` and `mail.brandCss` are inlined into the layout's style block. ## Sending At boot, `postcard.Activate` publishes one `postcard.Mailer` on the application, and `postcard.BootPlugin` registers each plugin's templates as it boots. Look the mailer up with `app.Lookup[postcard.Mailer]()` and send a `postcard.Message` with the template name, the recipients and the variables. Tests build the catalog and a memory driver directly, and read back what was sent: ```go // At boot, postcard.BootPlugin registers what pact.HasMailTemplates // declares; a test registers the same thing directly. cat := postcard.NewCatalog() err := cat.Register("acme.blog", mailFS, []string{"acme.blog::mail.welcome"}, map[string]string{"blog": "acme.blog::mail.layouts.blog"}) if err != nil { fmt.Println(err) return } driver := postcard.NewMemoryDriver() // mail.driver: memory mailer := postcard.NewMailer(cat, driver, postcard.Options{From: "blog@example.com"}) err = mailer.Send(context.Background(), postcard.Message{ Template: "acme.blog::mail.welcome", To: []string{"ada@example.com"}, Vars: map[string]any{"name": "Ada"}, }) if err != nil { fmt.Println(err) return } sent := driver.Messages()[0] fmt.Println(sent.From, sent.To, sent.Subject) fmt.Println(sent.Text) fmt.Println(strings.Contains(sent.HTML, `

Hi Ada`)) // Header injection is refused before any driver sees the message. err = mailer.Send(context.Background(), postcard.Message{ Template: "acme.blog::mail.welcome", To: []string{"ada@example.com\r\nBcc: all@example.com"}, Vars: map[string]any{"name": "Ada"}, }) fmt.Println(err != nil, len(driver.Messages())) // Output: // blog@example.com [ada@example.com] Welcome, Ada // Hi **Ada**, thanks for joining the blog. // // -- The Acme blog // true // true 1 ``` postcard does not pick a locale. For a per-language template, register one name per language (`acme.blog::mail.welcome_pl`) and pass the full name. Before a driver sees a message, postcard refuses a subject or address with a line break, parses every address with `net/mail`, and rejects rendered HTML that contains script, iframe, object or embed tags, inline event handlers, or `javascript:`, `vbscript:` or `data:` URLs. Variables are escaped by Go's `html/template`. `postcard.Mailer.Send` delivers before it returns. To keep a request fast, send from a queued job, and send after the write that triggered the mail has committed; see [Queued jobs](/docs/services/jobs.md) and [Transactions](/docs/database/transactions.md). ## Drivers `mail.driver` selects the driver: | Driver | Delivers | |--------|----------| | `memory` (default) | Nowhere: messages are kept in the process, for tests. | | `log` | To the log: headers and the text part, never the HTML part or credentials. For development. | | `smtp` | Through an SMTP server with the configured TLS policy. | The SMTP settings go in `config/mail.yaml`, with the password in the environment (`SUMMER_MAIL__SMTP__PASSWORD`): ```yaml driver: smtp from: blog@example.com smtp: host: smtp.example.com port: 587 username: blog password: tls: mandatory ``` `mail.smtp.tls` defaults to `mandatory`: the connection must upgrade with STARTTLS, and sending fails if the server does not offer it. postcard never infers a plain connection. The other two values exist for local mail catchers only: - `starttls` (or `opportunistic`) uses TLS when the server offers it and sends in plain text when it does not, so an attacker on the network can strip the upgrade. - `none` sends in plain text. > [!WARNING] > Use `starttls` or `none` only against a local development mail catcher. In production, keep the default `mandatory`. # Localization Source: /docs/services/localization.html Ship plugin translations in lang YAML catalogs, translate with :name placeholders and CLDR plurals through phrasebook, and read the request locale. WinterCMS plugins keep their strings in `lang//*.php` and read them with `Lang::get('acme.blog::lang.posts.title')` and `trans_choice`. SummerCMS keeps the key form and Laravel's message syntax; [phrasebook](/docs/api/phrasebook.md) loads the catalogs and translates. ## Catalogs A plugin ships `lang//.yaml` files and implements `pact.HasLang` to return them. Nested maps flatten into dotted keys under the plugin ID, so `title` in `lang/en/posts.yaml` of `acme.blog` is the key `acme.blog::posts.title`. At boot, `phrasebook.Activate` loads the framework strings and every plugin's catalogs and publishes one `phrasebook.Translator` on the application. Duplicate keys, malformed paths and values that are not strings fail the start-up. A plugin that implements `pact.HasLangOverrides` can replace keys of any loaded namespace, the framework's admin strings included, with files laid out as `lang///.yaml`. Overrides may also add a locale. ## Translating `phrasebook.Translator.Get` translates a key in the request locale, and `phrasebook.Translator.GetIn` in a locale you name. A lookup tries the locale, then its parent (`pt-BR`, then `pt`), then `app.fallback_locale`. A key that no locale has comes back unchanged, and outside production it is logged once. Placeholders follow Laravel: `:name` inserts the value, `:Name` capitalizes its first letter and `:NAME` upper-cases it: ```go cat := phrasebook.NewCatalog() if err := cat.Load("acme.blog", langFS); err != nil { fmt.Println(err) return } tr := phrasebook.NewTranslator(cat, phrasebook.Options{Locale: "en", Fallback: "en"}) // surf stores the request locale on the context; here the example does. ctx := towel.WithLocale(context.Background(), "pl") fmt.Println(tr.Get(ctx, "acme.blog::posts.title", nil)) // pl has no greeting, so the fallback locale answers. fmt.Println(tr.Get(ctx, "acme.blog::posts.greeting", map[string]string{"name": "Ada"})) fmt.Println(tr.GetIn("en", "acme.blog::posts.shout", map[string]string{"name": "Ada"})) // A missing key comes back as the key. fmt.Println(tr.Get(ctx, "acme.blog::posts.missing", nil)) // Output: // Posty // Hello, Ada // Welcome, ADA // acme.blog::posts.missing ``` Application code gets the published translator with `app.Lookup[*phrasebook.Translator]()`. ## Plurals `phrasebook.Translator.Choice` and `phrasebook.Translator.ChoiceIn` pick a plural form and fill in `:count`. A key can hold a map of CLDR plural categories (`one`, `few`, `many`, `other`, ...), checked against the categories the locale actually has, so a Polish string gets the forms Polish needs. Laravel's pipe syntax works too, with exact (`{0}`) and range (`[2,*]`) conditions: ```go cat := phrasebook.NewCatalog() if err := cat.Load("acme.blog", langFS); err != nil { fmt.Println(err) return } tr := phrasebook.NewTranslator(cat, phrasebook.Options{Locale: "en", Fallback: "en"}) for _, n := range []int{1, 3, 5, 22} { fmt.Println(tr.ChoiceIn("pl", "acme.blog::posts.count", n, nil)) } fmt.Println(tr.ChoiceIn("en", "acme.blog::posts.count", 5, nil)) for _, n := range []int{0, 1, 7} { fmt.Println(tr.ChoiceIn("en", "acme.blog::posts.drafts", n, nil)) } // Output: // 1 post // 3 posty // 5 postów // 22 posty // 5 posts // No drafts // One draft // 7 drafts ``` ## The request locale The locale lives on the request context, not in a global. For every route, [surf](/docs/api/surf.md) sets it from the `Accept-Language` header; the `locale.from-principal` middleware switches it to the signed-in user's preferred locale. Code reads it with `towel.Locale`, and code outside a request sets it with `towel.WithLocale`, as the example above does. A context without a locale uses `app.locale`. ## Strings for the admin The admin SPA receives its strings from the server. `phrasebook.Translator.Bundle` returns every key under a prefix as CLDR plural forms, merged over the fallback chain, and `phrasebook.Translator.Forms` returns one key. Start-up fails if an admin (`backend::`) string cannot be expressed as CLDR forms, so a pipe string with a condition the SPA cannot evaluate is caught before any admin sees it. The framework ships its validation messages (`lagoon::validate`) and admin strings (`backend::lang`) in English and Polish. # Storage Source: /docs/services/storage.html Configure the uploads bucket that serve opens, choose file or memory bucket URLs, serve stored files, and size upload routes. WinterCMS stores uploads on a Laravel filesystem disk. SummerCMS stores them in one [gocloud.dev](https://gocloud.dev/howto/blob/) bucket, opened from `storage.uploads.bucket_url` by the `attach` package of [lagoon](/docs/api/lagoon.md). The `serve` command opens the bucket at start-up, before it accepts requests, and publishes it on the application; an empty `bucket_url` stops the start-up. ## Bucket URLs | URL | Stores | |-----|--------| | `file:///var/lib/acme/uploads` | In a directory on the server. | | `mem://` | In memory, for tests. Everything is lost when the process exits. | The keys go in `config/storage.yaml`: ```yaml uploads: bucket_url: file:///var/lib/acme/uploads public_path_prefix: /storage/uploads ``` Files are laid out as WinterCMS lays out its uploads disk, so a copy of a WinterCMS `storage/app/uploads/public` directory can serve as the bucket after a port. `public_path_prefix` is the URL prefix that file and thumbnail URLs start with. Application code gets the bucket with `app.Lookup[*blob.Bucket]()` and reads and writes it through the `gocloud.dev/blob` API. Model attachments, thumbnails and deleting files after commit are covered in [Attachments](/docs/database/attachments.md). ## Serving files The framework does not mount a file route by itself. The application decides where files are served: mount `attach.StaticHandlerPublic` under `public_path_prefix` to serve originals and thumbnails and answer 404 for files whose row is not public, or put a web server or CDN in front of the bucket directory. Serve uploads from a separate origin when you can; the [Attachments](/docs/database/attachments.md) page explains why. ## Upload size Every non-raw route has a request body limit of `http.body_limits.default_bytes`. A route that accepts uploads raises its own limit with the `body.limit:` middleware; see [Routing](/docs/services/routing.md). `http.body_limits.upload_bytes` is required and validated at start-up, but the framework applies it to no route; a plugin that wants its upload routes to follow it reads it in `Register` and puts the value in the route's `body.limit`. # Outbound HTTP Source: /docs/services/outbound-http.html Fetch URLs that users or third parties supply through fetchguard, which allows HTTPS only, blocks private addresses at dial time and limits size and time. A WinterCMS plugin fetches a remote URL with the Laravel HTTP client or Guzzle, and checks the URL by hand when it came from a user. When a URL comes from outside the application, such as a remote image address, fetch it with [fetchguard](/docs/api/fetchguard.md). It is the framework's guard against server-side request forgery: a request that a user can aim at the application's own network, a cloud metadata service or an internal admin panel. For calls to services the application itself chose, such as a payment provider's API, the standard `net/http` client is fine. ## Policies Every call takes a `fetchguard.Policy`: - `fetchguard.AllowHostsMode` allows only the hosts in `AllowHosts`, matched exactly or as a dotted suffix. - `fetchguard.PublicOnlyMode` allows any public host. In both modes only `https` is allowed, and the resolved IP address is checked when the connection is dialled, so a DNS name that resolves into the network is refused too. The check covers private, loopback, link-local, carrier-grade NAT, documentation, multicast and other reserved IPv4 and IPv6 ranges, including IPv4 addresses inside NAT64 and 6to4 addresses. Environment proxy settings are ignored, so the check always sees the real target. ```go ctx := context.Background() // Only the application's image host, at most 5 MiB within 5 seconds. images := fetchguard.Policy{ Mode: fetchguard.AllowHostsMode, AllowHosts: []string{"images.example.com"}, MaxBytes: 5 << 20, Timeout: 5 * time.Second, } // Any public host, for a URL a user pasted. public := fetchguard.Policy{Mode: fetchguard.PublicOnlyMode} for _, c := range []struct { url string policy fetchguard.Policy }{ {"http://images.example.com/cover.jpg", images}, {"https://cdn.attacker.example/cover.jpg", images}, {"https://127.0.0.1/admin", public}, {"https://169.254.169.254/latest/meta-data/", public}, {"https://[::ffff:10.0.0.1]/", public}, {"https://%zz", public}, } { // The last argument is the application's config (app.Config), for // limits the policy leaves at zero; nil uses the framework defaults. _, err := fetchguard.Fetch(ctx, c.url, c.policy, nil) var fe *fetchguard.Error if errors.As(err, &fe) { fmt.Println(fe.Reason, c.url) } } fmt.Println(fetchguard.Defaults()) // Output: // scheme http://images.example.com/cover.jpg // invalid_url https://cdn.attacker.example/cover.jpg // private_ip https://127.0.0.1/admin // private_ip https://169.254.169.254/latest/meta-data/ // private_ip https://[::ffff:10.0.0.1]/ // invalid_url https://%zz // 10485760 10s ``` A failure is always a `fetchguard.Error` with one `fetchguard.Reason` from a closed set, so a handler can map it to a stable API error code. A host outside the allow list is reported as `invalid_url`, as the example shows. ## Responses and limits `fetchguard.Fetch` returns a `fetchguard.Result` with the body, the content type and the status code for any response the server completed, including 4xx and 5xx. Check the status yourself. Redirects are never followed: a 3xx response is returned as a result. To follow it, call `fetchguard.Fetch` again with the `Location` URL, which runs every check again. The body is capped at the policy's `MaxBytes` (a larger body is `fetchguard.ReasonTooLarge`) and the call at its `Timeout`. A limit left at zero falls back to `http.fetch.max_bytes` and `http.fetch.timeout_seconds` from the configuration you pass, then to the framework defaults of 10 MiB and 10 seconds (`fetchguard.Defaults`). A configured value of zero or less is an error, not a way to turn a limit off. # Queued jobs Source: /docs/services/jobs.html Declare background jobs with conga.Job, dispatch them inside the caller's transaction, track them in summer_jobs and run workers in serve or on their own. WinterCMS pushes slow work onto the Laravel queue and tracks long imports with a job manager. SummerCMS does both with [conga](/docs/api/conga.md): a plugin declares typed job functions, a caller dispatches them inside its own database transaction, and every dispatched job has a `summer_jobs` row that records its status, progress and outcome. The queue itself is River on the application's Postgres database, so there is no Redis or separate queue server to run. Plugin code never imports River. It only uses `conga.Job`, `conga.Manager` and the `pact.HasJobs` interface. ## Declaring jobs A job has two parts: an arguments type and a function. The arguments type implements `pact.JobArgs`: its `Kind` method names the job, and the value is stored as JSON in the queue, so give every field a `json` tag. `conga.Job` wraps a function that takes those arguments into a `pact.Job`. ```go // ImportPostsArgs are the arguments of the acme.blog post import job. type ImportPostsArgs struct { File string `json:"file"` } ``` ```go // Kind names the job. It must be unique across the application. func (ImportPostsArgs) Kind() string { return "acme_blog_import_posts" } ``` ```go job := conga.Job(func(ctx context.Context, args ImportPostsArgs) error { _, dispatched := conga.JobID(ctx) fmt.Println("import", args.File, "with a summer_jobs row:", dispatched) return nil }, conga.OnQueue("imports"), conga.MaxAttempts(5), conga.Timeout(10*time.Minute)) // A worker calls Work with the decoded arguments; a unit test can too. if err := job.Work(context.Background(), ImportPostsArgs{File: "posts.csv"}); err != nil { fmt.Println(err) } fmt.Println(ImportPostsArgs{}.Kind()) // Output: // import posts.csv with a summer_jobs row: false // acme_blog_import_posts ``` The options set the job's defaults: | Option | Sets | Default | |--------|------|---------| | `conga.OnQueue` | The queue the job is inserted on. | `default` | | `conga.MaxAttempts` | How many times the job is tried before its row is marked as an error. | `queue.max_attempts` (3) | | `conga.Timeout` | The deadline of each attempt. | `queue.job_timeout` (300 seconds) | `conga.JobID` returns the `summer_jobs` row of the running job. It reports `false` when the job has no row, as in the example above, where the function is called directly rather than by a worker. ## Registering jobs A plugin returns its jobs from `pact.HasJobs`. Every worker registers the jobs of every active plugin before it starts, so a job must be declared at build time; there is no way to add one while a worker runs (`conga.ErrRegistrationClosed`). A `pact.Job` that was not built by `conga.Job` is refused with `conga.ErrNotCongaJob`. A job that reports progress needs the job manager, so the plugin keeps it from `Register`: ```go // Jobs returns the plugin's background jobs; every worker registers them. func (p *BlogPlugin) Jobs() []pact.Job { return []pact.Job{ImportPostsJob(p.jobs)} } ``` `conga.From` returns the application's `conga.Manager`, publishing one on first use. ## Dispatching jobs Dispatch a job with `conga.Manager.Dispatch`, passing the transaction of the write that needs it. The `summer_jobs` row and the queued job are written on that transaction: when it rolls back, neither exists, and when it commits, a worker picks the job up at once. Laravel gives this guarantee only to jobs marked `afterCommit`; here every dispatch has it. ```go m, err := conga.From(app) if err != nil { return 0, err } var id uint err = db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { // ... write the import's own records on tx ... id, err = m.Dispatch(ctx, tx, ImportPostsArgs{File: file}, conga.DispatchOpts{ Label: "Import posts", Count: 1, }) return err }) return id, err ``` `conga.DispatchOpts` holds the row's `Label` (required), the initial `Count` for the progress bar and JSON `Metadata`, and can override the job's queue and attempt limit or delay the first attempt with `Delay`. The row also records who dispatched the job: the user ID and admin flag of the authenticated principal in the request context. When the handle you pass is not in a transaction, `Dispatch` opens one of its own. For fire-and-forget work that needs no row, `conga.Manager.Enqueue` inserts the job alone, inside the caller's transaction when there is one. ## The summer_jobs record The `summer_jobs` row is what the application reads to show progress. `conga.Manager.Get` returns it as a `conga.Record`, and its `conga.Record.Status` holds the WinterCMS job statuses: | Status | Meaning | |--------|---------| | `conga.StatusInQueue` | Defined for parity with WinterCMS. `Dispatch` never writes it. | | `conga.StatusInProgress` | Written by `Dispatch` and kept while the queue retries a failed attempt. | | `conga.StatusComplete` | The job completed its row, including skipped work recorded with `{"skipped": true}` metadata. | | `conga.StatusError` | The final attempt failed or panicked; the error text is in the metadata key `error`. | | `conga.StatusStopped` | The job was cancelled from outside or stopped itself. | ```go rec, err := m.Get(ctx, id) if err != nil { return "", err } switch rec.Status { case conga.StatusComplete: return fmt.Sprintf("%s: done (%d/%d)", rec.Label, rec.Progress, rec.ProgressMax), nil case conga.StatusError, conga.StatusStopped: return fmt.Sprintf("%s: failed or stopped", rec.Label), nil default: return fmt.Sprintf("%s: %d/%d", rec.Label, rec.Progress, rec.ProgressMax), nil } ``` ## Progress and cancellation A long job reports its progress and honours cancellation between items. The import job below sets the total with `conga.Manager.StartJob`, checks `conga.Manager.CheckIfCanceled` before each item, advances with `conga.Manager.UpdateJobState` and finishes with `conga.Manager.CompleteJob`: ```go // ImportPostsJob is the acme.blog import job. It reports progress on its // summer_jobs row, stops when the row is cancelled and completes the row. func ImportPostsJob(m *conga.Manager) pact.Job { return conga.Job(func(ctx context.Context, args ImportPostsArgs) error { id, _ := conga.JobID(ctx) rows := []string{args.File} // ... read the rows of args.File ... if err := m.StartJob(ctx, id, len(rows)); err != nil { return err } for i := range rows { canceled, err := m.CheckIfCanceled(ctx, id) if err != nil { return err } if canceled { return m.StopJob(ctx, id, nil) } // ... import rows[i] ... if err := m.UpdateJobState(ctx, id, i+1, nil); err != nil { return err } } return m.CompleteJob(ctx, id, map[string]any{"imported": len(rows)}) }, conga.OnQueue("imports")) } ``` Cancellation has two sides, as in the WinterCMS job manager: - `conga.Manager.CancelJob` is the cancel button. It marks the row as cancelled and stopped, then cancels the queued job, so a job that has not started never runs and a running job's context is cancelled. - `conga.Manager.StopJob` is what the job calls on its own row after `conga.Manager.CheckIfCanceled` reports `true`. It only sets the stopped status. The worker applies the outcome rules around each attempt. An error on an attempt before the last leaves the row in progress so the queue can retry it. The final failed attempt, or a panic on it, marks the row as an error. A job that returns `nil` without completing its row leaves the row as it is, so complete it yourself, as the example does. ## Running workers By default, `serve` runs a worker in the same process as the HTTP server, on every known queue. The known queues are `default`, `scheduled`, every queue in `queue.queues` and every queue a registered job names. To run jobs in separate processes, set `queue.work_in_serve` to `false` and start one or more workers: ```sh ./bin/acme queue:work ./bin/acme queue:work --queue imports --queue default ``` `queue:work` runs until it receives SIGINT or SIGTERM, then stops within 10 seconds. An unknown queue name is an error that lists the known ones. The worker settings live in `config/queue.yaml`: ```yaml work_in_serve: false max_attempts: 3 job_timeout: 300 queues: default: 4 imports: 1 ``` Each entry under `queues` is the number of jobs of that queue a worker runs at once. The worker listens for new jobs on a dedicated Postgres connection opened from `database.dsn`, so a job committed by any process starts without waiting for the poll interval. With PgBouncer, that connection must use session pooling or go straight to Postgres. To delete the waiting jobs of one queue, for example after a bad deploy, run `queue:clear`. Running jobs are never touched: ```sh ./bin/acme queue:clear imports ``` The worker also runs the scheduled console commands that plugins declare. See [Task scheduling](/docs/plugins/scheduling.md). # Realtime Source: /docs/services/realtime.html Publish model changes and events to realtime channels with lighthouse, authorize subscriptions per channel namespace, and run the Centrifugo driver. [lighthouse](/docs/api/lighthouse.md) is the SummerCMS counterpart of the WinterCMS websockets plugin. The application publishes events to named channels; the frontend holds a connection to a realtime server, subscribes to channels and receives the events. SummerCMS does not run the connection server itself. The Centrifugo driver publishes to a Centrifugo server through its HTTP API, issues the connection tokens the frontend needs, and answers Centrifugo's subscribe checks. ## Drivers `realtime.driver` selects the driver: | Driver | Publishes | |--------|-----------| | `null` (default) | Nothing. | | `log` | To the log: channel names and the event, never the payload. | | `memory` | Into memory, readable with `lighthouse.MemoryDriver.Publications`, for tests. | | `centrifugo` | To Centrifugo, from the `lighthouse/centrifugo` package. | A driver registers itself from its package's `init` function, as `database/sql` drivers do, so the application imports the driver package for its side effect: `_ ".../modules/lighthouse/centrifugo"`. An unknown driver name stops the start-up with the list of registered drivers. `lighthouse.From` returns the application's `lighthouse.Service`, which holds the driver, the authorizer registry and the broadcast settings. ## Channels and authorization A channel name is `namespace:entity:id`, optionally prefixed once with `presence:`. A plugin registers a `lighthouse.Authorizer` per namespace on the service's `lighthouse.Registry`. The driver asks the namespace's authorizer on every subscribe, so a user who loses access is refused the next time the client subscribes; nothing is cached: ```go app, err := newApp(map[string]any{"realtime.driver": "memory"}) if err != nil { fmt.Println(err) return } svc, err := lighthouse.From(app) if err != nil { fmt.Println(err) return } // blog:{entity}:{id} channels are open to members of the blog only. The // authorizer runs on every subscribe; nothing is cached. err = svc.Registry().Register("blog", lighthouse.AuthorizerFunc( func(ctx context.Context, userID uint, channel string) lighthouse.Result { if isMember(ctx, userID, lighthouse.ChannelID(channel)) { return lighthouse.Allowed(nil) } return lighthouse.Denied("not a member of the blog") })) if err != nil { fmt.Println(err) return } for _, sub := range []struct { user uint channel string }{{42, "blog:7"}, {42, "blog:8"}, {42, "presence:blog:7"}, {42, "shop:7"}} { ns, presence := lighthouse.ParseChannel(sub.channel) auth, ok := svc.Registry().Get(ns) if !ok { fmt.Println(sub.channel, "no authorizer") continue } res := auth.Authorize(context.Background(), sub.user, sub.channel) fmt.Printf("%d %s namespace=%s presence=%v allowed=%v reason=%q\n", sub.user, sub.channel, ns, presence, res.Allowed, res.Reason()) } fmt.Println(lighthouse.ChannelID("blog:12abc"), lighthouse.FormatChannels("acme", []string{"Blog:7"})) // Output: // 42 blog:7 namespace=blog presence=false allowed=true reason="" // 42 blog:8 namespace=blog presence=false allowed=false reason="not a member of the blog" // 42 presence:blog:7 namespace=blog presence=true allowed=false reason="not a member of the blog" // shop:7 no authorizer // 12 [acme:blog:7] ``` The channel rules follow the WinterCMS plugin byte for byte: - `lighthouse.ParseChannel` returns the namespace and whether the channel is a presence channel. A doubled `presence:` prefix or more than three segments give an empty namespace, which no authorizer matches. - `lighthouse.ChannelID` reads segment 1 with PHP's `(int)` cast: `12abc` is 12. For a `presence:` channel, segment 1 is the namespace, so the ID is 0, as the presence line of the example shows. An authorizer for presence channels must parse the ID itself. - `lighthouse.FormatChannels` lowercases channel names and applies the `realtime.broadcast_namespace` prefix. A denial's reason goes to the log only; the client always sees the same refusal. ## Mounting the driver's routes A driver may need HTTP routes. The Centrifugo driver has two: the token route, which signed-in users call, and the subscribe proxy, which Centrifugo calls. The application mounts them once, from a plugin's `Routes`, with `lighthouse.Mount`, choosing the middleware per surface: ```go app, err := newApp(map[string]any{"realtime.driver": "centrifugo"}) if err != nil { fmt.Println(err) return } svc, err := lighthouse.From(app) if err != nil { fmt.Println(err) return } // In a plugin's Routes method, r is the router the plugin receives. r := surf.New(nil) err = lighthouse.Mount(r, svc.Driver(), lighthouse.Surfaces{ UserAuth: surf.Use("acme.auth"), Middleware: surf.Use("throttle:60,1"), }) if err != nil { fmt.Println(err) return } for _, rt := range r.Routes() { fmt.Println(rt.Method, rt.Pattern, rt.Middleware, "raw:", rt.Raw) } // A user route without a guard is refused. err = lighthouse.Mount(surf.New(nil), svc.Driver(), lighthouse.Surfaces{}) fmt.Println(err != nil) // Output: // GET /api/realtime/token [acme.auth throttle:60,1] raw: false // POST /api/realtime/subscribe [throttle:60,1] raw: true // true ``` `lighthouse.UserAuth` routes get the `UserAuth` middleware, `lighthouse.ServerToServer` routes are mounted in a raw group, and `Middleware` is added to every route after the surface's own. A user route without a guard is refused, so the token route can never be exposed to anonymous callers. Switching drivers never changes the application's route declarations. ## Model broadcasts A model broadcasts its creates, updates and deletes when a `lighthouse.Binding` is registered for it, or when its pointer type implements `lighthouse.Broadcastable`. A binding keeps realtime code out of the models package: ```go return lighthouse.Bind[Post](svc, lighthouse.Binding[Post]{ Alias: "blog.post", Channels: func(ctx context.Context, tx *gorm.DB, p *Post) ([]string, error) { return []string{"blog:" + strconv.FormatUint(uint64(p.BlogID), 10)}, nil }, }) ``` The event name is `{action}.{alias}`, here `created.blog.post`, and the default payload is `{"model":...,"actor":...,"timestamp":"...+00:00","ttl":60}`. A binding's `Payload`, `ShouldBroadcast` and `TTL` fields, or the matching model methods, replace the defaults. Delivery is transactional. The write enqueues a broadcast job, through [conga](/docs/api/conga.md), inside its own transaction, so nothing is published for a write that rolls back, and the job publishes after the commit: ```go return lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error { if err := tx.Create(&Post{BlogID: 7, Title: "Hello"}).Error; err != nil { return err } // The broadcast job is now queued in this transaction. It is // published only if the transaction commits. if fail { return fmt.Errorf("rolled back") } return nil }) ``` The job runs once, best effort: a failed publish is logged as `realtime: broadcast failed` and never affects the write. Delivery order across separate jobs is not guaranteed. A job worker must be running, in `serve` or in `queue:work`; see [Queued jobs](/docs/services/jobs.md). A write without a primary key value, such as `Model(&Post{}).Where(...).Updates(...)`, is not broadcast. ## Bulk writes `lighthouse.WithoutBroadcasting` silences one model type for writes made with the context it hands to its function; other types still broadcast. `lighthouse.Service.Emit` enqueues one explicit event on the caller's transaction. Together they turn a thousand row events into one summary: ```go return lighthouse.WithoutBroadcasting[Post](ctx, func(ctx context.Context) error { return lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error { for _, title := range titles { if err := tx.Create(&Post{BlogID: 7, Title: title}).Error; err != nil { return err } } return svc.Emit(ctx, tx, lighthouse.Broadcast{ Channels: []string{"blog:7"}, Event: "blog.posts_imported", Payload: struct { Count int `json:"count"` }{len(titles)}, }) }) }) ``` Only writes that use the context passed to the function are silenced, so write through it, as `lagoon.Transaction` does above. ## The Centrifugo driver The driver reads `realtime.centrifugo.*` from `config/realtime.yaml`. Secrets go in the environment: ```yaml driver: centrifugo centrifugo: api_url: http://127.0.0.1:8001/api ws_url: /ws ``` with `SUMMER_REALTIME__CENTRIFUGO__API_KEY`, `SUMMER_REALTIME__CENTRIFUGO__TOKEN_SECRET` and `SUMMER_REALTIME__CENTRIFUGO__PROXY_SECRET` set. - The token route (`realtime.centrifugo.token_path`, `/api/realtime/token` by default) answers a signed-in user with `{"token":"..."}`, an HS256 connection token signed with the token secret. It answers 401 without a user and 503 when the token secret is empty. - The subscribe proxy (`realtime.centrifugo.subscribe_path`) accepts a call only when its `X-Centrifugo-Secret` header equals the proxy secret, compared in constant time; an empty proxy secret refuses every subscribe. It then asks the channel's authorizer. Every answer is HTTP 200, as Centrifugo requires, with the decision in the body. - Publishing uses the HTTP API with the API key. With an empty API key nothing is sent and no broadcast jobs are queued. The subscribe proxy runs the same authorizers as above: ```go svc, err := lighthouse.From(backpack.New(nil)) if err != nil { fmt.Println(err) return } // Members of blog 7 may subscribe to its channels. err = svc.Registry().Register("blog", lighthouse.AuthorizerFunc( func(ctx context.Context, userID uint, channel string) lighthouse.Result { if userID == 42 && lighthouse.ChannelID(channel) == 7 { return lighthouse.Allowed(nil) } return lighthouse.Denied("not a member of the blog") })) if err != nil { fmt.Println(err) return } // realtime.centrifugo.proxy_secret; set it through the environment. proxy := centrifugo.ProxyHandler(svc, centrifugo.Config{ProxySecret: "test-only-proxy-secret"}) // What Centrifugo posts to the subscribe proxy. subscribe := func(secret, user, channel string) { body := fmt.Sprintf(`{"client":"c1","user":%q,"channel":%q}`, user, channel) req := httptest.NewRequest(http.MethodPost, "/api/realtime/subscribe", strings.NewReader(body)) req.Header.Set("X-Centrifugo-Secret", secret) rec := httptest.NewRecorder() proxy.ServeHTTP(rec, req) fmt.Println(rec.Code, strings.TrimSpace(rec.Body.String())) } subscribe("test-only-proxy-secret", "42", "blog:7") subscribe("test-only-proxy-secret", "5", "blog:7") subscribe("wrong-secret", "42", "blog:7") // Output: // 200 {"result":{"info":[]}} // 200 {"error":{"code":403,"message":"Access denied"}} // 200 {"error":{"code":403,"message":"Access denied"}} ``` Configure Centrifugo to call the subscribe proxy with the same secret, and keep its HTTP API on a private address. `websockets:health` checks the connection to Centrifugo and prints the settings, never the API key: ```sh ./bin/acme websockets:health ``` # Web Push Source: /docs/services/push.html Send browser push notifications with flare over VAPID, only to https push service hosts on push.allowed_hosts and without redirects, and manage VAPID keys. [flare](/docs/api/flare.md) sends browser push notifications. Push is a different channel from realtime: realtime reaches pages that hold an open connection, while a push goes to the browser vendor's push service, which wakes the browser even when no page is open. flare is written on the standard library: the RFC 8291 payload encryption and the RFC 8292 VAPID authorization are implemented in the package, with no Web Push library. ## Subscriptions belong to the application When a browser subscribes, the frontend posts its `PushSubscription` (the endpoint URL and the `p256dh` and `auth` keys) to an application route, and the application stores it in its own table. flare never reads the database. Code that sends a push passes a `flare.Subscription` to the `flare.Pusher` that `flare.From` returns through `flare.Service.Pusher`. For the operator commands below, the application also publishes a `flare.SubscriptionSource` on the app, which reads a user's stored subscriptions. ## Sending `flare.Pusher.Send` encrypts the payload for the subscriber and posts it to the endpoint with the VAPID `Authorization` header. `flare.SendOptions` sets the `TTL` (default `push.ttl`), `Urgency` and `Topic` headers: ```go // A stand-in push service: 201 for a live subscription, 410 for one the // browser dropped. push := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/gone" { w.WriteHeader(http.StatusGone) return } fmt.Println("push service got", r.Header.Get("Content-Encoding"), r.Header.Get("TTL"), r.Header.Get("Urgency")) w.WriteHeader(http.StatusCreated) })) defer push.Close() keys, err := flare.GenerateVAPIDKeys() // websockets:generate-vapid-keys if err != nil { fmt.Println(err) return } cfg := flare.Config{ Enabled: true, PublicKey: keys.PublicKey, PrivateKey: keys.PrivateKey, Subject: "mailto:admin@example.com", TTL: time.Hour, AllowedHosts: []string{"127.0.0.1"}, // production keeps the default push services } // The test server's client trusts its certificate. pusher := flare.NewVAPIDPusher(cfg, push.Client()) ctx := context.Background() payload := []byte(`{"title":"New comment","body":"Someone replied to your post"}`) for _, endpoint := range []string{ push.URL + "/live", push.URL + "/gone", "https://push.attacker.example/steal", "http://127.0.0.1/plain", } { sub, err := browserSubscription(endpoint) if err != nil { fmt.Println(err) return } err = pusher.Send(ctx, sub, payload, flare.SendOptions{Urgency: "normal"}) switch { case err == nil: fmt.Println("sent") case errors.Is(err, flare.ErrSubscriptionGone): fmt.Println("gone: delete the subscription") case errors.Is(err, flare.ErrEndpointNotAllowed): fmt.Println("refused before connecting") default: fmt.Println("error:", err) } } // Formatting the keys never prints the private key. fmt.Println(strings.Contains(fmt.Sprintf("%v %#v", keys, keys), keys.PrivateKey)) // Output: // push service got aes128gcm 3600 normal // sent // gone: delete the subscription // refused before connecting // refused before connecting // false ``` - A 2xx answer is success. - 404 and 410 return `flare.ErrSubscriptionGone`: the browser unsubscribed, so delete the stored subscription. - Any other status returns a `flare.StatusError` with the code, never the response body. - A payload over `flare.MaxPayloadSize` (3993 bytes) returns `flare.ErrPayloadTooLarge`. - While `push.enabled` is false, nothing is sent and `flare.ErrPushDisabled` is returned. A send is one HTTP request with a 10-second timeout. Send from a queued job when a request would otherwise wait for it; see [Queued jobs](/docs/services/jobs.md). ## Endpoint safety Endpoints come from browsers, so they are untrusted URLs. flare sends only to `https` endpoints whose host is on `push.allowed_hosts`, checks this before it opens a connection, and never follows a redirect, so a push service cannot bounce the request to another host. A refused endpoint returns `flare.ErrEndpointNotAllowed`, which names the host but never the endpoint path. The default allowlist, `flare.DefaultAllowedHosts`, covers Firebase Cloud Messaging, Mozilla autopush, Apple and Windows push. `*.example.com` matches any subdomain but not `example.com` itself: ```go allowed := []string{"fcm.googleapis.com", "*.push.apple.com"} for _, host := range []string{"fcm.googleapis.com", "api.push.apple.com", "push.apple.com", "evil.example"} { fmt.Println(host, flare.HostAllowed(host, allowed)) } // Output: // fcm.googleapis.com true // api.push.apple.com true // push.apple.com false // evil.example false ``` Keep the default unless you know a browser your users run pushes through another service. ## VAPID keys A push service accepts a push only when it carries a token signed with the application's VAPID key pair. Generate the pair once: ```sh ./bin/acme websockets:generate-vapid-keys ``` It prints `SUMMER_PUSH__PUBLIC_KEY=...` and `SUMMER_PUSH__PRIVATE_KEY=...` lines to set in the environment. With `--update` it saves the keys to the environment's `overrides.yaml` instead. Keep the private key out of committed files. flare never writes the private key to a log or an error, and `flare.VAPIDKeys` and `flare.Config` redact it when printed. The frontend needs the public key to subscribe; serve it from a route of your own. Set `push.subject` to a `mailto:` or `https:` contact address for the push services, and `push.enabled` to `true`: ```yaml enabled: true subject: mailto:admin@example.com ``` in `config/push.yaml`. `websockets:test-push ` prints the push configuration without the key values, lists the user's subscriptions from the published `flare.SubscriptionSource`, and sends each one a test notification: ```sh ./bin/acme websockets:test-push 42 ``` # Search Source: /docs/services/search.html Keep models in a search index with beachcomber, synced after commit behind a kill-switch, and re-check the candidate IDs a search returns in SQL. [beachcomber](/docs/api/beachcomber.md) is the SummerCMS counterpart of Laravel Scout, as WinterCMS applications use it without a queue. A model opts in by implementing `beachcomber.Searchable`; after a write of such a model commits, its row is reloaded and its document written to, or removed from, the search index. A search asks the index for matching IDs, and the application loads the rows from the database. ## Engines `search.driver` selects the engine. The default, `null`, indexes nothing and finds nothing. The `typesense` engine, from the `beachcomber/typesense` package, talks to a Typesense server over its HTTP API. An engine registers itself from its package's `init` function, so the application imports the engine package for its side effect; an unknown name stops the start-up. `search.prefix` is prepended to every index name, for example to keep staging and production apart on one server: ```go cfg, err := compass.Open(compass.Options{Dir: "config", Env: "development", Environ: []string{}}) if err != nil { fmt.Println(err) return } _ = cfg.Set("search.prefix", "staging_") svc, err := beachcomber.From(backpack.New(cfg)) // search.driver defaults to null if err != nil { fmt.Println(err) return } fmt.Println(svc.Engine().Name(), svc.Engine().Configured(), svc.IndexName(&Post{})) ids, err := svc.Engine().SearchIDs(context.Background(), svc.IndexName(&Post{}), beachcomber.Query{Q: "go"}) fmt.Println(ids, err) _ = cfg.Set("search.driver", "elastic") _, err = beachcomber.From(backpack.New(cfg)) fmt.Println(err != nil) // Output: // null false staging_acme_blog_posts // [] // true ``` An engine implements `beachcomber.Engine` and registers with `beachcomber.RegisterEngine`. The examples on this page use a small in-memory engine that matches titles: ```go func (e *memoryEngine) SearchIDs(ctx context.Context, index string, q beachcomber.Query) ([]string, error) { e.mu.Lock() defer e.mu.Unlock() ids := []string{} for id, d := range e.docs[index] { if strings.Contains(strings.ToLower(fmt.Sprint(d["title"])), strings.ToLower(q.Q)) { ids = append(ids, id) } } slices.Sort(ids) return ids, nil } ``` ## Searchable models A model implements `beachcomber.Searchable` with three methods, and needs no import of beachcomber to do so: ```go // SearchableAs is the index name, before search.prefix. func (Post) SearchableAs() string { return "acme_blog_posts" } ``` ```go // ShouldBeSearchable keeps drafts out of the index. func (p *Post) ShouldBeSearchable() bool { return p.Published } ``` ```go // ToSearchableArray builds the document from the committed row. func (p *Post) ToSearchableArray(ctx context.Context, db *gorm.DB) (map[string]any, error) { return map[string]any{ "id": strconv.FormatUint(uint64(p.ID), 10), "blog_id": int64(p.BlogID), "title": p.Title, }, nil } ``` `ToSearchableArray` runs after the commit on a fresh copy of the row, so it may query related rows. A row whose `ShouldBeSearchable` is false, a soft-deleted row and a deleted row have their documents removed. A model can also implement `beachcomber.IndexSchemaProvider`, the schema the engine creates a missing index with, and `beachcomber.SearchKeyer`, a document key other than the primary key. ## Sync after commit `beachcomber.From` installs GORM callbacks that register the sync with `lagoon.AfterCommit`: - Inside `lagoon.Transaction`, the sync runs after the commit, and never after a rollback. - A single-statement write syncs after GORM commits it. - Inside a plain GORM transaction the sync is skipped with a warning, because the commit cannot be observed. Wrap such writes in `lagoon.Transaction`, or call `beachcomber.Service.Sync` after the commit. The sync runs inline in the writing goroutine, so a search right after a save finds the document, and it is bounded by the engine's timeout. It is never fatal: a failure is logged as `search: sync failed` with the index, key and operation, never the document or the API key, and the write stays committed. A write without a primary key value, such as `Model(&Post{}).Where(...).Updates(...)`, cannot be synced row by row; bulk paths call `beachcomber.Service.Sync` and `beachcomber.Service.Remove` per row, or reindex. ## The kill-switch Nothing is sent when the engine is not configured (the `null` engine, or Typesense without an API key), when no database is published, or when the application's `beachcomber.Gate` reports off. Install the gate from a plugin's `Boot`. A gate must treat a read error as off: ```go svc.SetGate(beachcomber.GateFunc(func(ctx context.Context, db *gorm.DB) bool { var enabled bool err := db.WithContext(ctx).Raw(`SELECT search_enabled FROM acme_blog_settings WHERE id = 1`).Scan(&enabled).Error return err == nil && enabled })) ``` ## Searching `beachcomber.Engine.SearchIDs` returns the IDs of matching documents, in the engine's order. They are candidates, not answers: the index can be stale (a write it missed, a document from before a permission change) and its filters are only as good as the document. Re-check every ID in SQL, with the same ownership, visibility and soft-delete conditions the rest of the API applies, before a row reaches a response: ```go ids, err := svc.Engine().SearchIDs(ctx, svc.IndexName(&Post{}), beachcomber.Query{ Q: term, QueryBy: []string{"title"}, FilterBy: "blog_id:=" + strconv.FormatUint(uint64(blogID), 10), }) if err != nil { return nil, err } // The ids are candidates from an index that may be stale or loosely // filtered: re-check every one in SQL before exposing a row. var posts []Post err = db.WithContext(ctx). Where("id IN ? AND blog_id = ? AND published", ids, blogID). Order("id"). Find(&posts).Error return posts, err ``` An empty result is an empty list, never an error. > [!WARNING] > Never return rows, or even counts, straight from search IDs. A stale index would otherwise show a draft, a deleted record or another user's data. ## Typesense The Typesense engine follows the Scout Typesense wire contract, so indexes built by a WinterCMS application can be searched by the port: ```yaml driver: typesense typesense: host: 127.0.0.1 port: 8108 protocol: http ``` in `config/search.yaml`, with the key in `SUMMER_SEARCH__TYPESENSE__API_KEY`. Without an API key nothing is ever sent. A search is one request to the collection's search endpoint, and the engine returns the hit IDs in Typesense's order: ```go // A stand-in Typesense node. node := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() fmt.Println(r.Method, r.URL.Path, "key sent:", r.Header.Get("X-TYPESENSE-API-KEY") != "") fmt.Println("q", q.Get("q"), "query_by", q.Get("query_by"), "filter_by", q.Get("filter_by")) fmt.Fprint(w, `{"hits":[{"document":{"id":"3"}},{"document":{"id":"1"}}]}`) })) defer node.Close() u, _ := url.Parse(node.URL) port, _ := strconv.Atoi(u.Port()) engine := typesense.New(typesense.Config{ APIKey: "test-only-key", // SUMMER_SEARCH__TYPESENSE__API_KEY Host: u.Hostname(), Port: port, Protocol: "http", ConnectionTimeout: 2 * time.Second, }) ids, err := engine.SearchIDs(context.Background(), "acme_blog_posts", beachcomber.Query{ Q: "go", QueryBy: []string{"title"}, FilterBy: "blog_id:=7", }) fmt.Println(ids, err) // Without an API key the engine is not configured and nothing is sent. fmt.Println(typesense.New(typesense.Config{}).Configured()) // Output: // GET /collections/acme_blog_posts/documents/search key sent: true // q go query_by title filter_by blog_id:=7 // [3 1] // false ``` Each request times out after `search.typesense.connection_timeout_seconds` (2 seconds by default). A failed answer is a `typesense.StatusError` with the method, path and status, never the answer body. # Parity testing Source: /docs/services/parity-testing.html Record the reference backend's responses and broadcasts with tide, replay them against the Go port and diff them after masking IDs and timestamps. When a plugin is ported from WinterCMS, its existing clients define the contract: the Go port must answer every request the way the PHP backend did. [tide](/docs/api/tide.md) turns that rule into tests. It records the reference backend's real responses as YAML fixtures, replays the same requests against the port, and diffs the responses after masking the values that legitimately differ. WinterCMS has no counterpart. ## Flows A fixture is a `tide.Flow`: a versioned, ordered list of steps, each a request with its recorded response. You write a spec, a flow with requests only: ```yaml version: 1 name: blog-posts description: List the posts of one blog steps: - id: list-posts route_id: GET /api/blog/posts request: method: GET path: /api/blog/posts?page=1 ``` Recording sends each request to the reference backend and fills in the responses. Replaying sends them to the port and compares status, a fixed set of contract headers and the body. JSON bodies are compared structurally after masking `id`, `*_id` and `*_ids` values and `*_at` timestamps; other bodies byte for byte: ```go ctx := context.Background() // The reference (PHP) backend and two Go ports. IDs and *_at timestamps // legitimately differ; the second port changed a title. reference := backend(`{"data":[{"id":12,"title":"Hello","created_at":"2026-09-30T10:00:00+00:00"}]}`) defer reference.Close() port := backend(`{"data":[{"id":3,"title":"Hello","created_at":"2026-10-01T08:30:00+00:00"}]}`) defer port.Close() broken := backend(`{"data":[{"id":3,"title":"hello","created_at":"2026-10-01T08:30:00+00:00"}]}`) defer broken.Close() raw, err := os.ReadFile("testdata/docs/posts-spec.yaml") if err != nil { fmt.Println(err) return } spec, err := tide.ParseFlow(raw) if err != nil { fmt.Println(err) return } // Record the reference once; the flow is what testdata/parity keeps. flow, err := tide.RecordFlow(ctx, spec, tide.RecordConfig{Target: reference.URL}) if err != nil { fmt.Println(err) return } for _, target := range []string{port.URL, broken.URL} { res, err := tide.ReplayFlow(ctx, flow, tide.ReplayConfig{Target: target}) var mismatch *tide.MismatchError if errors.As(err, &mismatch) { res = mismatch.Result // a difference is an error carrying the result } else if err != nil { fmt.Println(err) return } fmt.Println("ok:", res.OK) for _, step := range res.Steps { for _, d := range step.Diffs { fmt.Printf(" %s %s: want %s, got %s\n", step.ID, d.Path, d.Expected, d.Actual) } } } // Output: // ok: true // ok: false // list-posts $.data[0].title: want "Hello", got "hello" ``` The first port returns other IDs and timestamps and passes; the second changed a title and fails with the JSON path of the difference. A difference makes `tide.ReplayFlow` return a `tide.MismatchError` that carries the full `tide.Result`. ## The parity commands The `summer` CLI wraps tide. Run the reference backend and the port on loopback addresses: ```sh summer parity:record --spec testdata/parity/posts.spec.yaml --target http://127.0.0.1:8000 --output testdata/parity/posts.yaml --vars /tmp/parity/vars.yaml summer parity:replay --fixtures testdata/parity --target http://127.0.0.1:8080 --vars /tmp/parity/vars.yaml ``` `parity:proxy` records a real client instead: it runs a reverse proxy in front of the reference backend, and each named session of traffic becomes one fixture. Point the existing frontend at the proxy and click through a feature. The proxy binds to and forwards to loopback addresses only. Values captured during a flow, such as tokens and created IDs, live in a variables file (`--vars`) readable only by its owner, and fixtures refer to them as `{{name}}` placeholders. Recording refuses to write a fixture that still holds a token- or password-shaped value, so credentials do not end up in committed fixtures. Keep the variables file outside the repository. A route manifest (`tide.Manifest`) lists a plugin's routes with their auth groups, status and cases; `parity:record --manifest` records the missing cases in batches, and `parity:replay --manifest` reports coverage. The [Console utilities](/docs/console/utilities.md) page lists every flag. ## Broadcast goldens Realtime side effects are part of the contract too. `summer parity:broadcasts` runs a flow against the reference backend while a fake Centrifugo server (`tide.NewCentrifugoRecorder`) records the publications the backend sends, and writes them to a golden file: ```sh summer parity:broadcasts --flow testdata/broadcasts/flows/post-lifecycle.yaml --step delete --name deleted --target http://127.0.0.1:8000 --vars /tmp/parity/vars.yaml --out testdata/broadcasts/deleted.yaml ``` Point the reference backend's Centrifugo API URL at the recorder (`127.0.0.1:8424` by default). `--step` keeps only the publications of one step, running the earlier steps as setup. Timestamps, the actor and captured IDs are masked (`tide.NormalizePublications`), so the Go port's publications, recorded the same way, compare with `tide.DiffPublications`. A golden with `--pending` set is recorded but not yet asserted. On the Go side, the memory realtime driver records publications the same way in tests; see [Realtime](/docs/services/realtime.md). # Frontend and AJAX (not provided) Source: /docs/services/frontend-and-ajax.html SummerCMS is headless, so CMS pages, themes, components, the AJAX framework and Snowboard are not provided; build the frontend as a separate application. WinterCMS renders its frontend on the server: CMS pages and layouts in a theme, partials, components that plugins attach to pages, and the AJAX framework with Snowboard for handlers such as `onSave` that update parts of a page without a reload. SummerCMS provides none of these. It is headless: it serves a JSON API, realtime channels and the admin SPA, and the frontend is a separate application that talks to it. ## What is not provided | WinterCMS | In SummerCMS | |-----------|--------------| | CMS pages, layouts and partials in `themes/` | Not provided. The frontend application renders every page. | | Themes and the theme customisation form | Not provided. | | Components and `componentDetails`, `defineProperties`, `onRun` | Not provided. Expose the data a component loaded as a JSON route. | | The AJAX framework (`data-request`, `$this->page`, AJAX handlers) | Not provided. Call JSON routes with the frontend's own HTTP client. | | Snowboard and its plugins | Not provided. | | Twig and the Twig filters and functions | Not provided. | | Sessions and flash messages | Not provided. The API is stateless and authenticates each request with a token. | A WinterCMS plugin that shipped components and AJAX handlers is ported as routes: each component's data loading and each handler becomes a JSON endpoint declared through `pact.HasRoutes`. ## Building the frontend Build the frontend with any framework that can call a JSON API, as its own project with its own build and deployment: - **Data:** call the plugins' JSON routes. [Routing](/docs/services/routing.md) shows how routes, auth groups and JSON responses are declared, and [Queries and pagination](/docs/database/queries-and-pagination.md) the list envelope. - **Signing in:** the user plugin issues JWTs; send them as a bearer token or in the cookie the guard reads. See [Authentication](/docs/services/authentication.md). - **Live updates:** instead of polling an AJAX handler, subscribe to realtime channels. The frontend connects to Centrifugo with a token from the token route and receives model broadcasts and explicit events. See [Realtime](/docs/services/realtime.md). - **Cross-origin calls:** when the frontend runs on another origin, allow it in `http.cors`, as [Routing](/docs/services/routing.md) describes. - **Push notifications:** see [Web Push](/docs/services/push.md). The admin is the one frontend SummerCMS ships. It is a single-page app built the same way, against the admin API; see [Admin SPA](/docs/backend/admin-spa.md). The full map of what carries over from WinterCMS, and what does not, is on [Coming from WinterCMS](/docs/setup/coming-from-wintercms.md). # Console introduction Source: /docs/console/introduction.html The two command-line programs of SummerCMS, the summer developer tool and the application binary, and how summer delegates runtime commands. WinterCMS has one console entry point, `php artisan`. SummerCMS has two programs, because the developer tooling and the running application are separate binaries: | Program | Where it comes from | What it does | |---------|---------------------|--------------| | `summer` | Installed once from the framework with `go install ./cmd/summer`. | Builds and watches applications, scaffolds plugins and their parts, records API parity fixtures and builds these docs. | | The application binary, for example `./bin/acme` | Written by `summer build` into the application's `bin/` directory. | Runs the application: the HTTP server, migrations, workers, the scheduler, admin accounts and every command its plugins add. | The application binary is what you deploy, so everything that must run in production, such as migrations and workers, is a command of the binary rather than of `summer`. ## Getting help Both programs list their commands with `--help`, and every command accepts `--help` for its arguments and flags: ```sh summer --help summer make:model --help ./bin/acme --help ./bin/acme migrate:rollback --help ``` ## Commands that summer delegates During development you often work from the application directory with `summer` alone. These `summer` commands find the application's `summer.yaml`, build `bin/` when it does not exist yet, and run the same command of the binary with the same arguments: | summer command | Runs | |----------------|------| | `summer migrate` | `./bin/acme migrate` | | `summer migrate:rollback` | `./bin/acme migrate:rollback` | | `summer migrate:status` | `./bin/acme migrate:status` | | `summer serve` | `./bin/acme serve` | | `summer queue:work` | `./bin/acme queue:work` | | `summer queue:clear` | `./bin/acme queue:clear` | | `summer schedule:run` | `./bin/acme schedule:run` | `summer` does not rebuild an existing binary before it delegates. Run `summer build` after you change code, or keep `summer dev` running. Every other runtime command, such as `route:list`, `key:generate` or `admin:create`, is run on the binary directly. ## The sections of this chapter - [Setup and maintenance](/docs/console/setup-and-maintenance.md) lists every command of the application binary. - [Scaffolding](/docs/console/scaffolding.md) covers `summer build`, `summer dev` and the `make:` commands. - [Writing commands](/docs/console/writing-commands.md) shows how a plugin adds its own commands. - [Utilities](/docs/console/utilities.md) covers the parity and documentation commands of `summer`. # Setup and maintenance Source: /docs/console/setup-and-maintenance.html Every runtime command of an application binary, with its flags and purpose, from migrations and the server to workers, admin accounts and realtime checks. Every application binary carries the framework's runtime commands. The examples use a binary named `acme`; yours is named by `binary:` in `summer.yaml`. ## Migrations | Command | Flags | Purpose | |---------|-------|---------| | `migrate` | none | Runs the framework migrations, then every plugin's migrations in dependency order. | | `migrate:rollback` | `--plugin ` | Rolls back the last migration of the plugin; without the flag, of the last activated plugin that has migrations. | | `migrate:status` | none | Prints a table of plugin, history table and applied migration IDs. | ```sh ./bin/acme migrate ./bin/acme migrate:status ./bin/acme migrate:rollback --plugin acme.blog ``` Each plugin keeps its own migration history table, so rolling back one plugin never touches another. The migrations are listed in [lagoon](/docs/api/lagoon.md). ## Application key | Command | Flags | Purpose | |---------|-------|---------| | `key:generate` | none | Prints a fresh base64 32-byte key for `app.key`. It writes nothing and needs no database. | ```sh ./bin/acme key:generate ``` Copy the printed value into `SUMMER_APP__KEY`. When you rotate the key, keep the old one in `app.previous_keys` so existing encrypted columns can still be read. ## The HTTP server | Command | Flags | Purpose | |---------|-------|---------| | `serve` | `--addr` (default `:8080`) | Opens the database and uploads bucket, builds the router, starts the in-process job worker and serves HTTP until SIGINT or SIGTERM, then shuts down within 10 seconds. | | `route:list` | none | Builds the router the way `serve` does, without opening the database or listening, and prints every route with its method, pattern, plugin, middleware and raw flag. | ```sh ./bin/acme route:list ./bin/acme serve --addr 127.0.0.1:8080 ``` The default `--addr` listens on every interface. Pass a loopback address during development, and put a reverse proxy in front of the binary in production. The routing options are in [surf](/docs/api/surf.md). ## Admin accounts | Command | Arguments and flags | Purpose | |---------|---------------------|---------| | `admin:create` | `--email`, `--password` (both required), `--login`, `--role `, `--superuser` | Creates an activated backend administrator. `--login` defaults to the lower-cased email. | | `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 '' ``` Passwords passed as flags end up in your shell history. Prefer reading them from a secrets manager into a variable. The admin is described in [cabana](/docs/api/cabana.md). ## Queues and the scheduler | Command | Arguments and flags | Purpose | |---------|---------------------|---------| | `queue:work` | `--queue `, repeatable | Runs a job worker in the foreground on the named queues (default: every known queue) until SIGINT or SIGTERM. An unknown queue is an error that lists the known ones. | | `queue:clear` | `[queue]` (default `default`) | Deletes the waiting, scheduled and retryable jobs of one queue and prints how many it cleared. Running jobs are never touched. | | `schedule:run` | `--once` | Without `--once`, runs a scheduler-only worker until stopped. With `--once`, runs the entries due in the current minute and exits, for system cron. | ```sh ./bin/acme queue:work --queue default --queue imports ./bin/acme queue:clear imports ./bin/acme schedule:run --once ``` `serve` runs a job worker in the same process unless `queue.work_in_serve` is `false`; set it to `false` when you run `queue:work` separately. [Task scheduling](/docs/plugins/scheduling.md) explains the scheduler, and [conga](/docs/api/conga.md) the queue settings. ## Realtime and push These commands are not added by the generated `main`. An application that uses Centrifugo or Web Push appends them to the list one of its plugins returns from `pact.HasCommands`: `centrifugo.Commands` returns `websockets:health`, and `flare.Commands` returns the two push commands. | Command | Arguments and flags | Purpose | |---------|---------------------|---------| | `websockets:health` | none | Calls the Centrifugo `info` API and prints the configuration. Exits 1 when the API key is missing or the call fails. The key itself is never printed. | | `websockets:generate-vapid-keys` | `--update`, `--show-current` | Shows the configured VAPID keys, truncated, then generates a new pair. With `--update` it saves them to the environment's `overrides.yaml`; without it, it prints the variables to set by hand. | | `websockets:test-push` | ``, `--show-config` | Lists a user's push subscriptions and, after confirmation, sends one encrypted test notification to each. | ```sh ./bin/acme websockets:health ./bin/acme websockets:generate-vapid-keys --show-current ./bin/acme websockets:test-push 1 ``` The settings are in [lighthouse](/docs/api/lighthouse.md) and [flare](/docs/api/flare.md). # Scaffolding Source: /docs/console/scaffolding.html Build and watch an application, and generate plugins, models, migrations, console commands, jobs and admin controllers with the summer make commands. The `summer` tool builds applications and generates the files a plugin is made of, the way `create:plugin`, `create:model` and the other `create:` commands do in WinterCMS. Every generated file compiles as written, so you can build straight after running a command. ## Building and watching | Command | Purpose | |---------|---------| | `summer build` | Reads `summer.yaml`, generates `plugins.gen.go` and `main.go`, and builds the binary into `bin/`. Run it from the application directory or any directory below it. | | `summer dev` | Builds the application, starts the binary, and rebuilds and restarts it whenever a Go or YAML source, `go.mod`, `go.work`, `.env` or `summer.yaml` changes. | ```sh summer build summer dev ``` Do not edit `main.go` or `plugins.gen.go`: `summer build` rewrites them from the manifest. ## Creating a plugin `summer make:plugin` takes a plugin ID in `vendor.plugin` form and creates the plugin module in `plugins/` of the current application, with the directory layout described in [Plugin registration](/docs/plugins/registration.md): ```sh summer make:plugin acme.blog summer plugin:add plugins/blog ``` `summer plugin:add` then registers the local module: it adds the plugin to `summer.yaml`, adds a `require` and a local `replace` to the application's `go.mod`, and adds the directory to `go.work`. The next `summer build` compiles the plugin in. ## Generating plugin parts The other `make:` commands add one artifact to an existing plugin. Each takes the plugin ID and an exported Go name: ```sh summer make:model acme.blog Post summer make:migration acme.blog AddPublishedAt summer make:command acme.blog Publish summer make:job acme.blog ImportPosts summer make:admin-controller acme.blog Posts ``` When you run a command inside a plugin directory, leave the ID out and pass only the name. The tool finds the plugin from the nearest `plugin.go` above the current directory: ```sh cd plugins/blog summer make:model Comment ``` | Command | Writes | Notes | |---------|--------|-------| | `summer make:model` | `models/.go` and `updates/_create_

.go` | The table name is the plugin ID and the plural name in snake case, such as `acme_blog_posts`. `--no-migration` skips the migration. | | `summer make:migration` | `updates/_.go` | An empty gormigrate migration with up and down steps to fill in. | | `summer make:command` | `console/.go` | A `bonfire.Command` named `:`, such as `blog:publish`. | | `summer make:job` | `jobs/.go` | A typed job built with `conga.Job`; the plugin never imports the queue library. | | `summer make:admin-controller` | `controllers/.go`, `controllers//config_form.yaml`, `controllers//config_list.yaml`, `models//fields.yaml`, `models//columns.yaml` | A `pact.AdminController` with WinterCMS-shaped form and list configuration. | File names are the snake-case form of the name: `AddPublishedAt` becomes `add_published_at`. Migration file names start with a 14-digit timestamp, so they sort in the order you created them. A second migration with the same name in the same second gets the next second's timestamp, but migrations with different names created in the same second share one timestamp and sort by name; check the order in `updates/`, as [Porting a plugin](/docs/setup/porting-a-plugin.md) describes. After writing the files, every `make:` command regenerates the plugin's `registry.gen.go`, which lists the plugin's models, migrations, commands, jobs and admin controllers, and runs `go mod tidy` in the plugin. The capability methods of a scaffolded `plugin.go` return those generated lists. If your `plugin.go` was written by hand and does not call them, the command prints a note naming the accessors to add. A command refuses to overwrite an existing file or to declare a name the package already has. # Writing commands Source: /docs/console/writing-commands.html Add console commands to a plugin with bonfire.Command values, arguments, flags, styled output and prompts, and run commands in-process. A plugin adds console commands to the application binary the way a WinterCMS plugin calls `registerConsoleCommand`. In SummerCMS a command is a plain `bonfire.Command` value, and the plugin returns its commands from `pact.HasCommands`. `summer make:command acme.blog Publish` generates a starting point in `console/publish.go`. ## Defining a command A `bonfire.Command` has a name, a description, its positional arguments and flags, and a run function: - The name is in `namespace:verb` form, such as `blog:publish`. Plugin commands must use this form; only a few framework commands have bare names. - Each `bonfire.Arg` is a positional argument with a name, a description and a `Required` marker. The usage line shows required arguments as `` and optional ones as `[name]`. - Each `bonfire.Flag` is a string flag. Set `bonfire.Flag.Bare` for a switch such as `--dry-run` that stores `true` when given alone, and `bonfire.Flag.Repeatable` for a flag that can be given several times. - `bonfire.Command.Run` receives the context, a `bonfire.Input` and a `bonfire.Output`. Read arguments with `bonfire.Input.Argument`, scalar and bare flags with `bonfire.Input.Flag`, and repeatable flags with `bonfire.Input.Flags`, which returns the values in the order given: ```go publish := bonfire.Command{ Name: "blog:publish", Description: "Publish a post", Args: []bonfire.Arg{{Name: "slug", Description: "Post slug", Required: true}}, Flags: []bonfire.Flag{ {Name: "dry-run", Description: "Report without writing", Bare: true}, {Name: "tag", Description: "Tag to add (repeatable)", Repeatable: true}, }, Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error { slug, _ := in.Argument("slug") dryRun, _ := in.Flag("dry-run") out.Printf("publish %s, tags %v, dry run %s\n", slug, in.Flags("tag"), dryRun) return nil }, } // The generated main publishes a catalog of every command on the app. catalog := bonfire.NewCatalog([]bonfire.Command{publish}) args := []string{"hello-world", "--tag", "news", "--tag", "go", "--dry-run"} if err := catalog.Call(context.Background(), "blog:publish", args, os.Stdout); err != nil { fmt.Println(err) } // Output: publish hello-world, tags [news go], dry run true ``` ## Registering commands Return the commands from the plugin's `Commands` method, which implements `pact.HasCommands`. The generated `main` appends every plugin's commands after the framework's runtime commands. Command names are not checked for duplicates, so keep your commands in your plugin's own namespace, such as `blog:`. A scaffolded plugin's `Commands` method returns the generated list of everything in `console/`, so commands created with `summer make:command` are registered without editing `plugin.go`. ## Output `bonfire.Output` is the console your command writes to. Besides `bonfire.Output.Printf` and `bonfire.Output.Println`, it provides: - status lines: `bonfire.Output.Info`, `bonfire.Output.Success`, `bonfire.Output.Warning` and `bonfire.Output.Error` (the last one writes to the error stream); - widgets: `bonfire.Output.Table`, `bonfire.Output.Spinner` around a function and `bonfire.Output.Progress` for a progress bar, which fall back to plain lines when the output is not a terminal; - prompts: `bonfire.Output.Ask`, `bonfire.Output.Confirm`, `bonfire.Output.Choice` and `bonfire.Output.Secret`. Prompts return their defaults when input ends, so a command run from cron or a script never hangs. Return an error from `Run` to fail the command. The binary prints it and exits with status 1. ## Calling commands in-process `bonfire.Call` runs one command of a slice by name with its arguments and writes the output to any writer, like `Artisan::call` in Laravel. Tests use it to exercise a command without building a binary: ```go commands := []bonfire.Command{{ Name: "acme:greet", Description: "Greet someone by name", Args: []bonfire.Arg{{Name: "name", Required: true}}, Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error { name, _ := in.Argument("name") out.Printf("Hello, %s\n", name) return nil }, }} if err := bonfire.Call(context.Background(), commands, "acme:greet", []string{"blog"}, os.Stdout); err != nil { fmt.Println(err) } // Output: Hello, blog ``` The generated `main` also publishes the application's complete command list as a `bonfire.Catalog` on the container. Code outside the command line, such as the scheduler, looks it up and calls commands through `bonfire.Catalog.Call`, and checks for one with `bonfire.Catalog.Has`. ## Running your command After `summer build`, your command is part of the binary: ```sh ./bin/hello greeter:hello ``` That command comes from the greeter plugin of `examples/hello`, whose `Commands` method returns one `bonfire.Command`. # Utilities Source: /docs/console/utilities.html The summer commands for API parity testing and for building, syncing and previewing this documentation, with their flags. Besides building and scaffolding, the `summer` tool carries two groups of utility commands: API parity testing, used when you port an existing backend, and the documentation build. ## API parity commands When you port an existing backend to SummerCMS, its real responses are the contract your port must meet. The parity commands wrap [tide](/docs/api/tide.md): they record fixtures from the reference backend and replay them against the port. Every address they listen on or connect to must be a loopback address, and captured secrets go to a variables file with mode 0600, outside the committed fixtures. | Command | Flags | Purpose | |---------|-------|---------| | `summer parity:proxy` | `--listen` (default `127.0.0.1:8422`), `--upstream` (default `http://127.0.0.1:8423`), `--session`, `--rules`, `--vars`, `--fixtures`, `--update` | Runs a recording reverse proxy in front of the reference backend. Point a real client at it; each named session (from the `X-Parity-Session` header, or `--session`) is written as one fixture. | | `summer parity:record` | `--spec`, `--target`, `--output`, `--rules`, `--vars`, `--update`; `--manifest`, `--fixtures`, `--next-batch`, `--resume`, `--allow-incomplete`, `--require-recorded` | Sends the requests of a YAML spec to a target and records the responses as a fixture, or records the missing cases of a route manifest in batches of at most 15. | | `summer parity:replay` | `--fixtures`, `--target`, `--vars`, `--manifest`, `--self-check`, `--require-recorded` | Replays recorded fixtures against a backend and reports the differences after masking IDs and timestamps. | | `summer parity:broadcasts` | `--flow`, `--target`, `--vars`, `--listen` (default `127.0.0.1:8424`), `--out`, `--name`, `--step`, `--ids`, `--rules`, `--api-key`, `--settle` (default `500ms`), `--pending` | Runs a flow against the reference backend with a fake Centrifugo server and records the realtime publications it sends into a golden file. | A typical port records once against the reference backend and replays against the Go backend on every change: ```sh summer parity:record --spec testdata/parity/posts.spec.yaml --target http://127.0.0.1:8000 --output testdata/parity/posts.yaml --vars /tmp/parity/vars.yaml summer parity:replay --fixtures testdata/parity --target http://127.0.0.1:8080 --vars /tmp/parity/vars.yaml ``` ## Documentation commands These docs are Markdown files under `docs/`, plus every module README, built into a static site by `summer`. Run the commands from the framework root. | Command | Flags | Purpose | |---------|-------|---------| | `summer docs:build` | `--root` (default `.`), `--src`, `--out`, `--base-url`, `--site-url`, `--site-label`, `--check` | Checks every page and writes the site to `site/` (or `--out`): HTML pages, a raw `.md` copy of each page, `llms.txt`, `llms-full.txt` and the search index. With `--check` it only reports problems and writes nothing. | | `summer docs:sync` | `--root` (default `.`), `--src` | Rewrites every code block that has a `src=` reference from its source file. | | `summer docs:serve` | `--root` (default `.`), `--src`, `--base-url`, `--site-url`, `--site-label`, `--addr` (default `127.0.0.1:8088`), `--allow-remote` | Builds the site into a temporary directory, serves it and rebuilds when a page, a module or a referenced source changes. A failed rebuild prints its problems and keeps serving the last good build. | `docs/site.yaml` accepts two optional keys, `site_url` and `site_label`, that add a link back to the main site to every page header. With `site_url: https://acme.example/` the link reads "acme.example". Without `site_label` the label is the URL's host, or Home when `site_url` is a path such as `/`. `site_url` must be an `http://` or `https://` URL with a host, or a path starting with a single `/`. The `--site-url` and `--site-label` flags override the two keys the way `--base-url` overrides `base_url`. ```sh summer docs:build --check summer docs:sync summer docs:serve ``` `docs:build` fails, and writes nothing, when a page names an identifier that does not exist, links to a missing page or anchor, shows a command that neither `summer` nor an application binary has, or has a `src=` code block that differs from its source. It also fails on a Go code block (including `golang`) without a `src=` reference, a `src=` code block inside a callout, blockquote or list item (`src=` blocks must be top-level), and a `src=` code block in a module README. A `src=` target must be code `go test ./...` compiles and runs, that is a file in the default build, inside a `Test` function or an `Example` with an `// Output:` comment, or a function one of them calls. After you change code that a page shows, run `summer docs:sync` to refresh the copies. `docs:serve` listens only on a loopback address unless you pass `--allow-remote`. Use it to preview search, which browsers block when you open the built files directly from disk. # backpack Source: /docs/api/backpack.html Per-instance application container that holds the configuration, a typed service registry, the event bus and the set of activated plugins. `import "git.golem15.com/golem15/summercms/modules/backpack"` ## Overview `backpack` plays the role of the Laravel service container that WinterCMS plugins reach through `App::make` and singleton bindings, without any process-global state: every `backpack.App` is independent, so tests and multiple instances in one process do not interfere. The generated `main` of an application loads configuration with [compass](/docs/api/compass.md), creates the container with `backpack.New`, and hands it to [party](/docs/api/party.md), which passes it to every plugin's Register and Boot. `backpack` deliberately does not import `party`, which keeps the dependency graph acyclic. ## Features - `backpack.New` wires a container around a loaded `*compass.Config`: `backpack.App.Config`, a fresh service registry in `backpack.App.Services` and a new [festival](/docs/api/festival.md) bus in `backpack.App.Events`. - Typed services keyed by the type argument: `backpack.App.Publish` stores a value under its type T and `backpack.App.Lookup` returns it. Publishing the same T twice or publishing nil is an error, so two plugins cannot silently replace each other's service. Framework modules share their infrastructure this way (for example the database handles published by [lagoon](/docs/api/lagoon.md), or the translator from [phrasebook](/docs/api/phrasebook.md)). - Plugin presence checks: `backpack.App.SetPlugins` records the complete activated set before any plugin boots, and `backpack.App.HasPlugin` answers whether an optional integration partner is part of this build (the equivalent of WinterCMS's `PluginManager::exists`). - `backpack.Registry` can be used on its own through `backpack.NewRegistry`; it is safe for concurrent use. - Nil-safe methods: calls on a nil container or registry return an error or a zero value instead of panicking. ## Usage ```go package blog import ( "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/compass" ) // Feed is a service the blog plugin offers to other plugins. type Feed interface { Latest(n int) []string } type staticFeed struct{} func (staticFeed) Latest(n int) []string { return []string{"hello-world"} } func setup() error { cfg, err := compass.Load("config") if err != nil { return err } app := backpack.New(cfg) app.SetPlugins([]string{"acme.blog", "acme.search"}) // Provider side, usually in the plugin's Register step. var feed Feed = staticFeed{} if err := app.Publish(feed); err != nil { // published under Feed, not staticFeed return err } // Consumer side, usually in another plugin's Boot step. if app.HasPlugin("acme.blog") { if found, ok := app.Lookup[Feed](); ok { _ = found.Latest(5) } } return nil } ``` Publish under an interface type when consumers should not depend on the concrete implementation; the lookup must use exactly the same type argument. ## API reference | Identifier | Description | |------------|-------------| | `backpack.App` | The application container: configuration, service registry, event bus and the activated plugin set. | | `backpack.New` | Creates a `backpack.App` around a loaded configuration, with an empty registry and a new event bus. | | `backpack.App.Publish` / `backpack.App.Lookup` | Store and retrieve an app-scoped service by type. | | `backpack.App.SetPlugins` / `backpack.App.HasPlugin` | Record the activated plugin IDs and check whether one is present. | | `backpack.Registry` | Concurrency-safe typed service catalog behind `backpack.App.Services`. | | `backpack.NewRegistry` | Returns an empty registry. | | `backpack.Registry.Publish` / `backpack.Registry.Lookup` | Registry-level publish and lookup behind the `backpack.App` methods. | ## Dependencies - SummerCMS modules: [compass](/docs/api/compass.md), [festival](/docs/api/festival.md). - Third-party: none. - Standard library: `fmt`, `reflect`, `sync`. ## Testing ```sh go test ./modules/backpack/... ``` The tests build containers in memory and need no external services. # beachcomber Source: /docs/api/beachcomber.html Search index sync for GORM models: after-commit upserts and deletes through a pluggable engine, gated by an application kill-switch. `import "git.golem15.com/golem15/summercms/modules/beachcomber"` `import _ "git.golem15.com/golem15/summercms/modules/beachcomber/typesense"` ## Overview beachcomber is the SummerCMS counterpart of Laravel Scout as WinterCMS applications use it with `queue=false`. A model opts in by implementing `beachcomber.Searchable`. After a create, update or delete of such a model commits, the row is reloaded by primary key and its document is upserted into the engine's index, or removed from it. Sync runs inline in the writing goroutine, bounded by the engine's request timeout, and is never fatal: a failure is logged and the write stays committed. The package itself knows no search server. An engine package registers itself from its `init` function, the way `database/sql` drivers do, and the application picks one with `search.driver`. The built-in `null` engine indexes nothing. The `typesense` sub-package is a hand-rolled `net/http` client for Typesense that follows the Scout `TypesenseEngine` wire contract. `beachcomber.From` builds the app-scoped `beachcomber.Service` on first use and publishes it on the app. It installs the sync GORM callbacks through `lagoon.OnDatabase`, so they reach the production handle even though plugins boot before `serve` publishes the database. The application installs a `beachcomber.Gate`, its kill-switch, with `beachcomber.Service.SetGate`. ## Features - Engine selection by `search.driver`: `null` (the default) or a registered engine such as `typesense`. An unknown name is a boot error that lists the registered engines. Third-party engines register with `beachcomber.RegisterEngine` and a `beachcomber.EngineFactory`; a duplicate name panics at init. - The `beachcomber.Searchable` model contract: `SearchableAs` (the index name, prefixed with `search.prefix`), `ToSearchableArray(ctx, db)` (the document, built from the committed row and free to query related rows) and `ShouldBeSearchable`. A model can also implement `beachcomber.IndexSchemaProvider` (the schema the engine creates a missing index with) and `beachcomber.SearchKeyer` (a document key other than the decimal primary key). - The `beachcomber.Engine` driver contract: `Name`, `Configured`, `Upsert`, `Delete`, `Flush` and `SearchIDs` with a `beachcomber.Query`. - GORM callbacks `beachcomber.CallbackAfterCreate`, `beachcomber.CallbackAfterUpdate` and `beachcomber.CallbackAfterDelete`, installed once per `*gorm.DB`, register the sync with `lagoon.AfterCommit`. - `beachcomber.Service.Sync` and `beachcomber.Service.Remove` run the same gated path on demand, for reindex tooling, and return the error instead of logging it. - Typesense engine (`typesense.Engine`, engine name `typesense`): - Every request carries the `X-TYPESENSE-API-KEY` header. - `Upsert` reads the collection and creates it from the schema on 404. A 409 on create counts as success, and a model without a schema gets an auto-typed collection. It then imports the documents as JSON lines (`Content-Type: text/plain`) with `action=upsert`. Typesense answers 200 even when a document fails, so every answer line is checked and any `"success":false` line is an error. - `Delete` and `Flush` treat 404 as success. - `SearchIDs` sends `q` (default `*`), `query_by`, `filter_by`, `sort_by`, `page` and `per_page`, and returns `hits[].document.id` in order. - Ids and index names are path-escaped. - A non-2xx answer is a `typesense.StatusError` with the method, path and status, never the answer body. ## Sync semantics - **After commit.** The callbacks register the sync with `lagoon.AfterCommit`. Inside `lagoon.Transaction` it runs after that transaction commits, and not at all when it rolls back. A single-statement write, for which GORM opens its own transaction, syncs after that commit and not when the write fails. Inside a plain `gorm` transaction Lagoon cannot observe the commit, so `lagoon.AfterCommit` logs a warning and the sync is skipped; wrap such writes in `lagoon.Transaction`, or call `Sync` after the commit. The sync's reads run in a savepoint, so when `Sync` or `Remove` is handed a transaction a failed read never aborts it, including a read that the application Gate swallows and counts as off. - **Inline and non-fatal.** The sync runs in the writing goroutine, after the commit, so a create followed by a search sees the document. Every engine request is bounded by the engine's timeout (`search.typesense.connection_timeout_seconds`), and the caller's context cancellation does not abandon it. A failure, a timeout or a panic is logged at Warn as `search: sync failed` with the index, key and operation. The write is already committed and stays so. The log never carries the document or the API key. - **Three gates, before any request.** Nothing is sent when: 1. the engine is not configured (the `null` engine, or Typesense with an empty `search.typesense.api_key`); 2. no `*gorm.DB` is published on the app (a fresh install); 3. the application `beachcomber.Gate` reports off. A gate must treat a read error as off. When the engine is not configured, the callbacks do not even register work. - **Reload, then decide.** The row is reloaded by primary key, including soft-deleted rows. A delete, a row that is gone, a soft-deleted row (a set `gorm.DeletedAt`) or a row whose `ShouldBeSearchable` is false has its document deleted. Restoring a soft-deleted row is an ordinary update and indexes it again. An error from `ToSearchableArray` is logged and nothing is sent, which lets a model refuse a document that would break scoping. A document without an `id` gets the key. - **Rows only.** A statement without a primary key value, such as `Model(&T{}).Where(…).Updates(…)` or `Delete(&T{}, id)`, cannot be synced row by row and is skipped. Bulk paths call `beachcomber.Service.Sync` or `beachcomber.Service.Remove` per row, or reindex. - **Candidates, not answers.** `beachcomber.Engine.SearchIDs` returns candidate ids from an external index that may be stale. Callers must re-gate every id in SQL (ownership, visibility, soft deletes) before they expose a row. An empty result is an empty list, never an error. ## Usage An application selects the engine in `config/search.yaml`: ```yaml driver: typesense typesense: api_key: "" # set with SUMMER_SEARCH__TYPESENSE__API_KEY ``` A model implements `beachcomber.Searchable` without importing beachcomber: ```go package models func (Post) SearchableAs() string { return "acme_blog_posts" } func (Post) ShouldBeSearchable() bool { return true } func (p *Post) ToSearchableArray(ctx context.Context, db *gorm.DB) (map[string]any, error) { return map[string]any{ "id": strconv.FormatUint(uint64(p.ID), 10), "blog_id": int64(p.BlogID), "title": p.Title, }, nil } func (Post) SearchIndexSchema() map[string]any { return map[string]any{ "fields": []map[string]any{ {"name": "id", "type": "string"}, {"name": "blog_id", "type": "int64"}, {"name": "title", "type": "string"}, }, } } ``` The plugin imports the engine package for its side effect, builds the service at Boot and installs its kill-switch: ```go package acme import ( "context" "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/beachcomber" _ "git.golem15.com/golem15/summercms/modules/beachcomber/typesense" "gorm.io/gorm" ) func (p *Plugin) Boot(app *backpack.App) error { svc, err := beachcomber.From(app) if err != nil { return err } svc.SetGate(beachcomber.GateFunc(func(ctx context.Context, db *gorm.DB) bool { return acmeSearchEnabled(ctx, db) // false on any read error })) return nil } ``` A search endpoint asks the engine for candidate ids: ```go ids, err := svc.Engine().SearchIDs(ctx, svc.IndexName(&models.Post{}), beachcomber.Query{ Q: term, QueryBy: []string{"title"}, FilterBy: "blog_id:=" + strconv.FormatUint(uint64(blogID), 10), }) ``` ## API reference ### beachcomber | Identifier | Description | |------------|-------------| | `beachcomber.From(app)` | The app's `*beachcomber.Service`, built and published on first use. | | `beachcomber.Service` | The search service: `SetGate`, `Engine`, `Prefix`, `IndexName`, `Logger`, `Sync`, `Remove`. | | `beachcomber.Searchable` | `SearchableAs()`, `ToSearchableArray(ctx, db)`, `ShouldBeSearchable()`. | | `beachcomber.IndexSchemaProvider` | `SearchIndexSchema()`: the schema a missing index is created with. | | `beachcomber.SearchKeyer` | `SearchKey()`: replaces the decimal primary key as the document key. | | `beachcomber.Engine` | `Name`, `Configured`, `Upsert`, `Delete`, `Flush`, `SearchIDs`. | | `beachcomber.Query` | `Q`, `QueryBy`, `FilterBy`, `SortBy`, `Page`, `PerPage`. | | `beachcomber.Gate`, `beachcomber.GateFunc` | The application kill-switch: `Enabled(ctx, db) bool`. | | `beachcomber.EngineFactory`, `beachcomber.RegisterEngine(name, factory)` | Registers an engine from an `init` function. | | `beachcomber.NullEngine`, `beachcomber.DefaultDriver` | The name of the built-in engine that indexes nothing, and the default of `search.driver`. | | `beachcomber.CallbackAfterCreate`, `beachcomber.CallbackAfterUpdate`, `beachcomber.CallbackAfterDelete` | Names of the GORM callbacks. | ### beachcomber/typesense | Identifier | Description | |------------|-------------| | `typesense.Config`, `typesense.LoadConfig` | The `search.typesense.*` settings with their defaults; `BaseURL` is `{protocol}://{host}:{port}{path}`. | | `typesense.Engine`, `typesense.New` | The `beachcomber.Engine`, with `Config`. | | `typesense.StatusError` | A non-2xx answer: `Method`, `Path`, `Code` and `StatusCode()`. | | `typesense.DriverName` | `typesense`. | | `typesense.DefaultHost`, `typesense.DefaultPort`, `typesense.DefaultProtocol`, `typesense.DefaultConnectionTimeout`, `typesense.DefaultImportAction` | Defaults of the configuration keys. | ## Configuration | Key | Default | Description | |-----|---------|-------------| | `search.driver` | `null` | `null`, or a registered engine such as `typesense`. | | `search.prefix` | `""` | Prefix applied to every index name. | | `search.typesense.api_key` | `""` | API key; empty means nothing is ever sent. | | `search.typesense.host` | `localhost` | Typesense node host. | | `search.typesense.port` | `8181` | Typesense node port. | | `search.typesense.protocol` | `http` | `http` or `https`. | | `search.typesense.path` | `""` | Path prefix of the node. | | `search.typesense.connection_timeout_seconds` | `2` | Per-request timeout, in seconds or as a duration string. | | `search.typesense.import_action` | `upsert` | The `action` of document imports. | ## Dependencies - `backpack`, `compass` and `lagoon` (callback installation and `lagoon.AfterCommit`) from this repository. - `gorm.io/gorm` (sync callbacks and reloads). - The Typesense client is plain `net/http`; no Typesense SDK is used. ## Testing ```bash go test ./modules/beachcomber/... ``` A test points `search.typesense.host` and `search.typesense.port` at an `httptest` server to see the exact Typesense requests. Sync is inline, so the requests have arrived when the write returns. # boardwalk Source: /docs/api/boardwalk.html HTTP handler that serves the embedded admin SPA build under a configurable path prefix. `import "git.golem15.com/golem15/summercms/modules/boardwalk"` ## Overview `boardwalk` embeds the compiled admin SPA (`dist/`, produced by `npm --prefix admin run build`) into the binary and serves it. The build is path-agnostic: `index.html` carries a placeholder token that the handler replaces once, at construction, with the prefix the admin is mounted under, so one build works at any backend URI. [cabana](/docs/api/cabana.md) mounts it when it activates the admin routes. It stands in for the server-rendered backend layouts of WinterCMS, which the Go port replaces with a single-page app. ## Features - Serves the embedded build under any prefix, rewriting relative asset URLs and the admin base meta in `index.html` for that prefix (`boardwalk.RewriteIndex`). - Fails at boot when `index.html` lacks the `boardwalk.BaseToken` placeholder, which catches a stale or hand-edited build. - Falls back to `index.html` for client-side routes and directories; missing files with an extension get a plain 404. - Hands every request whose path under the prefix is `api` or starts with `api/` to a caller-supplied handler, so admin API misses stay JSON instead of returning the SPA. - Long-lived immutable caching for hashed files under `assets/`, `no-cache` for other files and `no-store` for `index.html`. - Security headers on every response: a restrictive Content-Security-Policy, frame denial, `nosniff`, a same-origin referrer policy and `noindex, nofollow`. - Explicit content types for scripts, styles, fonts, SVG and JSON, with a MIME lookup fallback. ## Usage ```go notFoundAPI := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte(`{"error":"not found"}`)) }) spa, err := boardwalk.Handler("/backend", notFoundAPI) if err != nil { return err } mux.Handle("/backend/", spa) ``` ## API reference | Identifier | Description | |------------|-------------| | `boardwalk.Handler` | Builds the SPA handler for a prefix; the second argument answers unmatched `api/` paths. | | `boardwalk.Dist` | Returns the embedded build as an `fs.FS` rooted at `dist/`. | | `boardwalk.RewriteIndex` | Rewrites raw `index.html` bytes for a prefix; errors when the placeholder token is missing. | | `boardwalk.BaseToken` | The placeholder in `dist/index.html` that is replaced by the prefix. | | `boardwalk.ContentType` | The Content-Type served for a file name, with explicit UTF-8 JavaScript and CSS types. Shared with the plugin asset route in cabana. | | `boardwalk.SetSecurityHeaders` | Sets the admin security headers (nosniff, referrer policy, no framing, the `script-src 'self'` CSP, noindex) on a response. | ## Dependencies - SummerCMS modules: none. - Third-party: none. - Standard library: `bytes`, `embed`, `errors`, `fmt`, `html`, `io/fs`, `mime`, `net/http`, `path`, `strings`, `time`. The embedded `dist/` tree is generated from the `admin/` Vite project, whose build writes to `modules/boardwalk/dist`. ## Testing ```sh go test ./modules/boardwalk/... ``` The tests run the handler through `net/http/httptest` against the embedded build and in-memory file systems; they need no external services. # bonfire Source: /docs/api/bonfire.html Declarative console commands for the `summer` tool and application binaries, adapted to Cobra with typed input, prompts and styled output. `import "git.golem15.com/golem15/summercms/modules/bonfire"` ## Overview bonfire is the console layer of SummerCMS. Plugins and framework modules describe commands as plain `bonfire.Command` values (name, flags, arguments and a run function), and `bonfire.NewRoot` turns a slice of them into a Cobra root command. Commands never touch Cobra directly: they read arguments through `bonfire.Input` and write through `bonfire.Output`, which also provides tables, spinners, progress bars and interactive prompts. It is the counterpart of WinterCMS's artisan console commands (`registerConsoleCommand` and Laravel's `Illuminate\Console\Command` output helpers). ## Features - Command values with a description, positional arguments (`bonfire.Arg`) and string flags (`bonfire.Flag`), collected from plugins or the tool itself. - Command name validation: plugin commands must use the `namespace:verb` form (for example `blog:import`); `build`, `dev`, `serve` and `migrate` are the only bare names accepted. Invalid names make `bonfire.NewRoot` fail with `bonfire.ErrCommandName`. - Usage strings and argument-count checks derived from the declared arguments (`` for required, `[name]` for optional). - Scalar flags, bare flags (`bonfire.Flag.Bare`, so `--force` alone stores `true`) and ordered repeatable flags (`bonfire.Flag.Repeatable`, read back through `bonfire.Input.Flags`). - Styled status lines: `bonfire.Output.Info`, `bonfire.Output.Success`, `bonfire.Output.Warning` and `bonfire.Output.Error` (the last one writes to the error stream). - Widgets: box-drawn tables (`bonfire.Output.Table`), a spinner around a function (`bonfire.Output.Spinner`) and a progress bar (`bonfire.Output.Progress`); both fall back to plain lines when output is not a terminal. - Prompts: `bonfire.Output.Ask`, `bonfire.Output.Confirm`, `bonfire.Output.Choice` and `bonfire.Output.Secret`, which reads a hidden value on a terminal. Prompts return their defaults when input ends, and `bonfire.Output.Confirm` returns its default without asking when the session is not interactive. - Injectable streams (`bonfire.NewRootIO`, `bonfire.NewOutput`) so commands can be tested against buffers. - In-process calls: `bonfire.Call` runs a named command with arguments against any writer (Laravel `Artisan::call`), and `bonfire.Catalog` holds an application binary's final command list so code outside the Cobra root, such as the conga scheduler, can call any registered command. ## Usage ```go package main import ( "context" "os" "git.golem15.com/golem15/summercms/modules/bonfire" ) func main() { importPosts := bonfire.Command{ Name: "blog:import", Description: "Import posts from a feed", Args: []bonfire.Arg{{Name: "url", Description: "Feed URL", Required: true}}, Flags: []bonfire.Flag{ {Name: "dry-run", Description: "Report without writing", Bare: true}, {Name: "tag", Description: "Tag to apply (repeatable)", Repeatable: true}, }, Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error { url, _ := in.Argument("url") _, dryRun := in.Flag("dry-run") return out.Spinner("Importing "+url, func() error { out.Table([]string{"Tag"}, [][]string{{"news"}}) if dryRun { out.Warning("dry run: nothing written") } return nil }) }, } root, err := bonfire.NewRoot("acme", []bonfire.Command{importPosts}, os.Stdout) if err != nil { os.Exit(1) } if err := root.Execute(); err != nil { os.Exit(1) } } ``` Running a command in-process, with empty stdin so prompts take their defaults: ```go catalog := bonfire.NewCatalog(commands) if catalog.Has("blog:import") { err := catalog.Call(ctx, "blog:import", []string{"--dry-run", "https://example.com/feed"}, os.Stdout) if errors.Is(err, bonfire.ErrUnknownCommand) { // not registered in this binary } } ``` ## API reference | Identifier | Description | |------------|-------------| | `bonfire.Command` | A console command: name, description, flags, arguments and the `bonfire.Command.Run` function. | | `bonfire.Flag` | A string flag; `bonfire.Flag.Bare` allows the flag without a value, `bonfire.Flag.Repeatable` makes it an ordered multi-value flag. | | `bonfire.Arg` | A positional argument with a name, description and required marker. | | `bonfire.Input` | Parsed view handed to `bonfire.Command.Run`: `bonfire.Input.Args`, `bonfire.Input.Argument`, `bonfire.Input.Flag` and `bonfire.Input.Flags`. | | `bonfire.Output` | Injected console: printing, status lines, tables, spinner, progress bar and prompts. | | `bonfire.Progress` | A progress bar advanced from inside `bonfire.Output.Progress`. | | `bonfire.NewRoot` | Builds the Cobra root command for a binary from a slice of commands, using the process stdin. | | `bonfire.NewRootIO` | `bonfire.NewRoot` with injected stdin, stdout and stderr. | | `bonfire.NewOutput` | Builds a `bonfire.Output` over the given streams, applying the terminal and color policy. | | `bonfire.ErrCommandName` | Returned when a plugin command name is not in `namespace:verb` form. | | `bonfire.Call` | Runs one command of a slice by exact name with arguments, writing output to a writer; stdin is empty. | | `bonfire.ErrUnknownCommand` | Returned by `bonfire.Call` when no command has the name. | | `bonfire.Catalog` | An immutable copy of a binary's command list; the generated app main publishes one on the app. | | `bonfire.NewCatalog` | Builds a `bonfire.Catalog` from a command slice. | ## Configuration bonfire reads no config keys. Output color follows these environment variables: | Variable | Effect | |----------|--------| | `NO_COLOR` | Any non-empty value disables color. | | `TERM` | The value `dumb` disables color. | | `FORCE_COLOR` | Any non-empty value enables color even when output is not a terminal (ignored when color is disabled by `NO_COLOR` or `TERM`). | Without these variables, color is enabled only when stdout is a terminal. ## Dependencies - SummerCMS modules: none. - Third-party: `github.com/spf13/cobra`, `golang.org/x/term`. - Standard library: `bufio`, `context`, `errors`, `fmt`, `io`, `os`, `strconv`, `strings`, `sync`, `time`, `unicode/utf8`. ## Testing ```sh go test ./modules/bonfire/... ``` The tests use in-memory streams and need no external services. # bouncer Source: /docs/api/bouncer.html Authentication for SummerCMS: HS256 JWT minting, verification and refresh, request guards, a revoked-token blacklist and bcrypt password helpers. `import "git.golem15.com/golem15/summercms/modules/bouncer"` ## Overview bouncer decides who is making a request. Guards (`bouncer.Guard`, `bouncer.CredentialGuard`) turn an `*http.Request` into a `bouncer.Principal`; a `bouncer.Registry` holds named guards that plugins register and turns each one into HTTP middleware that stores the principal on the request context. The JWT side issues and checks HS256 tokens for two audiences, frontend users (`bouncer.AudienceUser`) and admin users (`bouncer.AudienceBackend`), with a refresh flow and a jti blacklist compatible with tokens issued by the PHP jwt-auth library. It is the counterpart of WinterCMS's Auth and BackendAuth facades and the JWT auth layer used by API plugins. ## Features - Token minting with `bouncer.Mint` (frontend audience) and `bouncer.MintAudience` (any audience), each returning the signed token and its random jti. - Verification with HS256 pinned and `exp` and `sub` required: `bouncer.Verify` and `bouncer.VerifyClaims` accept frontend tokens, including legacy tokens with no audience claim; `bouncer.VerifyClaimsAudience` requires an explicit audience, so a backend token cannot pass a frontend check and the other way round. - Refresh with `bouncer.Refresh`, `bouncer.RefreshAudience` and `bouncer.RefreshAudienceFor`: an expired token can be reissued while its `iat` is inside the refresh window; the old jti is blacklisted after a grace period. `bouncer.RefreshAudienceFor` also reloads the user and refuses deleted users and tokens issued before `bouncer.Principal.TokensValidAfter`, reporting `bouncer.ErrSubjectRejected`. - JWT guards: `bouncer.NewJWTGuard` (frontend) and `bouncer.NewBackendJWTGuard` (admin audience, optional custom 401 writer) read the bearer token first and then any configured cookies, load the user through a `bouncer.UserProvider`, check the blacklist and the `bouncer.Principal.TokensValidAfter` cutoff, and write a JSON 401 body (`{"error":true,"message":...}`, with `Cache-Control: no-cache, private`) on failure. - Named guard registry: `bouncer.Registry.Register` accepts any `bouncer.Guard` or `bouncer.CredentialGuard`; `bouncer.Registry.Middleware` derives middleware that stores the principal (and credential, if any) on the context. Guards that do not implement `bouncer.UnauthorizedWriter` let unauthenticated requests through so later middleware can decide. - Standalone bearer middleware: `bouncer.Middleware`. - Context helpers: `bouncer.WithUser` and `bouncer.User` for the principal, `bouncer.WithCredential` and `bouncer.Credential` for the credential behind it (for example an API token record). - Blacklist stores behind `bouncer.BlacklistStore`: `bouncer.MemoryBlacklist` for tests and `bouncer.PostgresBlacklist` for production, which works on a caller-supplied table with `jti`, `expires_at` and `valid_until` columns and rejects unsafe table names. - Passwords: `bouncer.HashPassword`, `bouncer.CheckPassword` and `bouncer.NeedsRehash` (bcrypt, cost chosen by the caller). ## Usage ```go package blog import ( "context" "fmt" "net/http" "time" "git.golem15.com/golem15/summercms/modules/bouncer" ) type users struct{} // FindByID loads the user behind a token subject; nil means "not found". func (users) FindByID(ctx context.Context, id uint) (*bouncer.Principal, error) { return &bouncer.Principal{ID: id, PreferredLocale: "en"}, nil } func Routes(secret string) (http.Handler, error) { guards := bouncer.NewRegistry() guard := bouncer.NewJWTGuard(secret, users{}, bouncer.NewMemoryBlacklist(), "token") if err := guards.Register("acme.blog", "jwt", guard); err != nil { return nil, err } auth, err := guards.Middleware("jwt") if err != nil { return nil, err } mux := http.NewServeMux() mux.Handle("GET /api/me", auth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { user, _ := bouncer.User(r.Context()) fmt.Fprintf(w, "user %d", user.ID) }))) return mux, nil } func Login(secret string) (string, error) { token, _, err := bouncer.Mint(secret, "42", "https://example.com/api/login", time.Hour) return token, err } ``` ## API reference | Identifier | Description | |------------|-------------| | `bouncer.Principal` | The authenticated identity: user ID, locale override, token cutoff and admin permission grants. | | `bouncer.Guard` | Resolves the `bouncer.Principal` for a request. | | `bouncer.CredentialGuard` | Resolves the principal and its underlying credential in one pass. | | `bouncer.UnauthorizedWriter` | Optional guard interface for writing its own 401 response. | | `bouncer.UserProvider` | Loads a user by numeric token subject. | | `bouncer.Registry` | Named guard registry; `bouncer.NewRegistry` creates one. | | `bouncer.Registry.Register` | Registers a guard under a name on behalf of a plugin; duplicate names fail. | | `bouncer.Registry.Middleware` | Returns HTTP middleware for a registered guard; unknown names fail. | | `bouncer.NewJWTGuard` | Frontend JWT guard reading the bearer header and optional cookies. | | `bouncer.NewBackendJWTGuard` | Admin JWT guard that requires the backend audience. | | `bouncer.Middleware` | Standalone middleware that validates a bearer token and loads the user. | | `bouncer.Mint` | Signs a frontend-audience token; returns the token and its jti. | | `bouncer.MintAudience` | Signs a token for a given audience. | | `bouncer.Verify` | Verifies a frontend token and returns its subject. | | `bouncer.VerifyClaims` | `bouncer.Verify` plus `iat`, `exp` and `jti`. | | `bouncer.VerifyClaimsAudience` | `bouncer.VerifyClaims` with a required audience. | | `bouncer.Refresh` | Reissues a frontend token inside the refresh window and blacklists the old jti. | | `bouncer.RefreshAudience` | Refresh for a token that carries the given audience. | | `bouncer.RefreshAudienceFor` | `bouncer.RefreshAudience` plus the guard's user checks. | | `bouncer.ErrSubjectRejected` | The token subject is not a loadable user, or the token predates the user's cutoff. | | `bouncer.AudienceUser`, `bouncer.AudienceBackend` | The frontend and admin audience values. | | `bouncer.WithUser`, `bouncer.User` | Store and read the principal on a context. | | `bouncer.WithCredential`, `bouncer.Credential` | Store and read the resolved credential on a context. | | `bouncer.BlacklistStore` | Revoked-jti store with a grace window and sweeping. | | `bouncer.NewMemoryBlacklist` | In-process blacklist for tests. | | `bouncer.NewPostgresBlacklist` | Blacklist over a `*sql.DB` and a table name. | | `bouncer.HashPassword` | Returns a bcrypt hash at the given cost. | | `bouncer.CheckPassword` | Reports whether a password matches a hash. | | `bouncer.NeedsRehash` | Reports whether a hash was made below the configured cost. | ## Dependencies - SummerCMS modules: none. - Third-party: `github.com/golang-jwt/jwt/v5`, `golang.org/x/crypto/bcrypt`. - Standard library: `context`, `crypto/rand`, `database/sql`, `encoding/hex`, `encoding/json`, `errors`, `fmt`, `math`, `net/http`, `reflect`, `regexp`, `strconv`, `strings`, `sync`, `time`. - Tests additionally use `github.com/testcontainers/testcontainers-go` with its `modules/postgres` package, and `github.com/jackc/pgx/v5/stdlib`. ## Testing ```sh go test ./modules/bouncer/... ``` The Postgres blacklist concurrency test starts a PostgreSQL container through testcontainers-go and needs Docker. Run `go test -short ./modules/bouncer/...` to skip it; the remaining tests need no external services. # cabana Source: /docs/api/cabana.html 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](/docs/api/pact.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](/docs/api/surf.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](/docs/api/boardwalk.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](/docs/api/phrasebook.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](/docs/api/lagoon.md); 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 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 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. Unknown keys, a foreign or invalid tag, an unregistered action or a fill key that is not a writable scalar field fail boot. - Controller assets: a controller implementing `pact.AdminClientAssets` names JS (`.js`, `.mjs`) and CSS files under its plugin's `assets/` directory, Winter's `addJs`/`addCss`. They are read from the plugin's embedded tree at boot (a missing file fails boot; there is no disk override) and listed in the list and form schemas under `assets` as same-origin URLs with a `?v=` content hash. A form with a widget needs at least one JS file. - Toolbar actions: `toolbar.buttons` in `config_list.yaml` lists the built-in `create` and `delete` next to names the controller registers through `pact.HasAdminActions`. Registered actions share one namespace with widget actions, `create` and `delete` are reserved, and each toolbar action needs a label. The list schema's `toolbarActions` carries only the actions the requesting administrator may run, with localized labels; an unknown name fails boot. - Server-rendered partials: `headerPartial: ` in `config_list.yaml` (a strip above the list) and `type: partial` with `path: ` in `fields.yaml` render the template `{ConfigDir}/_.htm` with `html/template` against a view model from the controller's `pact.AdminPartialData`. The result reaches the SPA as an allowlisted node tree, never as an HTML string. A missing or unparsable template, a free-form path or a controller without `pact.AdminPartialData` fails boot. - Singleton settings screens declared with `pact.HasSettings`, read and saved by `cabana.SettingsService`. - Backend navigation (`pact.HasNavigation`) and permissions (`pact.HasPermissions`), filtered per user by `cabana.Registry.Metadata`. `cabana.Allows` implements the permission check: superusers pass, and grants ending in `.*` match by prefix. - Admin authentication against WinterCMS's `backend_users` and `backend_user_roles` tables (`cabana.BackendUser`, `cabana.BackendUserRole`, `cabana.BackendUsers`): a JWT guard registered in [bouncer](/docs/api/bouncer.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`. 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 and resetting their passwords (see CLI commands). ### Admin API routes All paths are relative to `/api/v1`. A controller ID `vendor.plugin.controller` maps to the path `/{vendor}/{plugin}/{controller}`. | Method and path | Purpose | |-----------------|---------| | POST `/auth/login`, POST `/auth/refresh` | Sign in (throttled) and refresh a token. Public. | | GET `/lang` | The `backend::lang` string bundle for the request locale. Public, so the login screen can load it. | | POST `/auth/logout`, GET `/auth/me` | Revoke the current token; return the signed-in administrator. | | GET `/navigation`, GET `/settings` | Navigation and settings entries the administrator may open. | | GET `/settings/{code}/schema`, GET and PUT `/settings/{code}` | Settings form schema, values and update. | | GET `/{vendor}/{plugin}/{controller}/schema/list`, `.../schema/form`, `.../schema/relation/{name}` | Localized list, form and relation schemas. | | GET and POST `/{vendor}/{plugin}/{controller}` | List records; create a record. | | GET, PUT and DELETE `/{vendor}/{plugin}/{controller}/{id}` | Show, update and delete a record. | | POST `/{vendor}/{plugin}/{controller}/bulk-delete` | Delete a set of records in one transaction. | | POST `/{vendor}/{plugin}/{controller}/widgets/{field}` | Run the action of a `type: widget` field with an optional `record_id` and the fill snapshot; answers `{message, fill}`. | | POST `/{vendor}/{plugin}/{controller}/toolbar/{action}` | Run a registered toolbar action with an empty `{}` body; answers `{message, fill: {}}`. | | GET `/{vendor}/{plugin}/{controller}/partials/{name}` | Render a declared header or form partial as a node tree; `?id=` (form partials only) passes the scoped record to the view model. | | GET `.../fields/{field}/options`, GET `.../filters/{scope}/options` | Choices for a relation field and for a model-backed list filter. | | GET `.../{id}/relations/{name}`, GET `.../{id}/relations/{name}/candidates` | Linked records and link candidates of a relation manager. | | POST `.../{id}/relations/{name}/link`, POST `.../{id}/relations/{name}/unlink` | Link and unlink related records. | Every path under the prefix that no API route matches is served by the admin SPA; unmatched API paths return the `not_found` error envelope instead. ### Partials A partial is an `html/template` file next to the controller's YAML: `headerPartial: stats` and `path: stats` both resolve to `{ConfigDir}/_stats.htm`; Winter's `$/` and `~/` paths are not supported. The template's root is `.Data`, the value the controller's `PartialData(ctx, name, record)` returns, and `trans ""` translates a phrase key in the request locale. `record` is nil for a header partial and for a form partial on the create form; with `?id=` it is the record cabana loaded through the controller's `pact.FormExtendQuery` scope, so a plugin never looks a record up by a request id itself. The view model must be a curated struct built for the template. cabana 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: ```html
{{ trans "acme.blog::lang.stats.posts" }}
{{ .Data.Total }}
``` ### Partial style kit and plugin CSS variables The admin SPA ships a small set of stable CSS classes that partial templates may use through the allowlisted `class` attribute, so server-rendered content looks native without any plugin CSS: | Class | Use | |-------|-----| | `summer-partial` | Set by the SPA on every partial's root: 14px/1.5 body text, long words and URLs wrap, `p`/`ul`/`ol` spaced 8px apart, links underlined with the focus ring. | | `summer-stats` | A card strip (surface background, border, 16px radius, card shadow, 16px 20px padding) whose items wrap onto more rows with a 32px column gap and an 8px row gap. Safe on a `
`. | | `summer-stat` | One item of the strip: the value is shown above the label while `
` stays first in the DOM. | | `summer-stat__label` | The item label: 13px, muted, wraps. | | `summer-stat__value` | The item value: 20px, weight 600, tabular numbers. | Use `
` with one `
` per item holding a `
` and a `
`, as in the example above. Plugin CSS (declared through `pact.AdminClientAssets`) and any widget shadow DOM may read only these public variables. They inherit into shadow roots and switch automatically in dark mode: `--c-bg`, `--c-surface`, `--c-subtle`, `--c-border`, `--c-border-strong`, `--c-text`, `--c-muted`, `--c-placeholder`, `--c-primary`, `--c-on-primary`, `--c-danger`, `--c-danger-soft`, `--c-hover`, `--c-sel`, `--c-skel`, `--c-ring`. Plugins must not hardcode hex colours and must not rely on Tailwind utility classes: the SPA build purges every utility it does not use itself. A controller's stylesheets are disabled while another controller's list or form is open. ### Controller assets `GET /assets/{vendor}/{plugin}/{file...}` serves the files controllers declare through `pact.AdminClientAssets`. A plugin file `assets/js/lookup.js` of plugin `acme.blog` is served at `/assets/acme/blog/js/lookup.js`, and the schemas list it as `/assets/acme/blog/js/lookup.js?v=`. The route is public, like the SPA shell, and serves only the exact files declared at boot, never the plugin's embedded tree: YAML and templates are not reachable, and any other path falls through to the SPA, which also serves its own build assets under `/assets/`. Each response carries an explicit JavaScript or CSS `Content-Type`, `X-Content-Type-Options: nosniff`, the admin Content-Security-Policy (`script-src 'self'`), `Cross-Origin-Resource-Policy: same-origin`, `Cache-Control: no-cache` and a sha256 `ETag`, so conditional requests answer 304 and a rebuilt binary is picked up at once. ## Usage A plugin exposes an admin controller and embeds its YAML. `summer make:admin-controller` scaffolds the controller type and its four YAML files: ```go package blog import ( "embed" "io/fs" "git.golem15.com/golem15/summercms/modules/pact" ) // adminFS holds controllers/post/config_list.yaml, controllers/post/config_form.yaml, // models/post/columns.yaml and models/post/fields.yaml. // //go:embed controllers models var adminFS embed.FS type Post struct { ID uint `gorm:"primaryKey"` Title string `gorm:"column:title"` } func (Post) TableName() string { return "acme_blog_posts" } type postAdmin struct{} func (postAdmin) ID() string { return "acme.blog.post" } func (postAdmin) ModelName() string { return "Post" } // must equal modelClass in the YAML func (postAdmin) ConfigDir() string { return "controllers/post" } func (postAdmin) NewRecord() any { return &Post{} } // pact.AdminRecordSource // Plugin also implements party.Plugin (ID, Requires, Register, Boot). type Plugin struct{} func (p *Plugin) AdminControllers() []pact.AdminController { return []pact.AdminController{postAdmin{}} } func (p *Plugin) AdminFS() fs.FS { return adminFS } ``` `config_list.yaml` and `config_form.yaml` reference the model files with WinterCMS paths such as `~/plugins/acme/blog/models/post/columns.yaml`. At boot, `surf.BuildRouter` calls `cabana.Activate` with the activated plugins and mounts the returned `cabana.Routes`; when no plugin registers an admin controller, `cabana.Activate` returns nil and no admin routes exist. Record and user lookups use the `*gorm.DB` that [lagoon](/docs/api/lagoon.md) publishes on the `backpack.App`. ## API reference | Identifier | Description | |------------|-------------| | `cabana.Activate` | Compiles every plugin's admin controllers, settings, navigation and permissions and returns the admin `cabana.Routes`, or nil when there are no controllers. | | `cabana.Routes` | Guard middleware, mount function and normalized prefix of the admin API and SPA. | | `cabana.AdminPrefix` | Reads and validates `backend.uri`; `cabana.DefaultAdminPrefix` is the fallback. | | `cabana.RuntimeCommands` | Returns the `admin:create` and `admin:reset-password` commands. | | `cabana.CompileList` / `cabana.CompileForm` | Compile a controller's list and form YAML into cached schemas. | | `cabana.ListSchema` / `cabana.FormSchema` / `cabana.RelationSchema` | Locale-neutral compiled schemas; each request works on a localized copy. | | `cabana.CompiledController` | One controller after compilation: list, form, relations and writable fields. | | `cabana.Registry` | Immutable map of compiled controllers and settings, with permission-filtered metadata. | | `cabana.CRUDService` | Schema-projected show, create, update, delete, bulk delete and relation options. | | `cabana.ExecuteList` | Runs an allowlisted, paginated list query for a controller. | | `cabana.RelationService` | Linked, candidate, link and unlink operations of relation managers. | | `cabana.SettingsService` | Reads and transactionally updates singleton settings rows. | | `cabana.FieldRelationProvider` / `cabana.FieldRelationContract` | Controller-supplied bindings for `type: relation` form fields. | | `cabana.AdminRelationContractProvider` / `cabana.RelationContract` | Controller-supplied bindings for relation managers. | | `cabana.BackendUser` / `cabana.BackendUserRole` / `cabana.BackendUsers` | GORM models of the backend user tables and the principal loader used by the guard. | | `cabana.Allows` | Checks a principal against required permission codes. | | `cabana.WriteData` / `cabana.WriteError` / `cabana.WriteErrorDetails` | Write the admin success and error envelopes. | | `cabana.ValidationError` / `cabana.ListValidationError` | Field-level `validation_failed` errors. A `pact.AdminAction` may return a `cabana.ValidationError` to answer 422. | | `cabana.AdminActionRequest` | Body of an action route: optional `record_id` and the widget's `values`. Unknown keys are refused. | | `cabana.AdminActionResult` | Answer of an action route: the localized `message` and the filtered `fill` object. | | `cabana.ControllerAssets` | The `assets` object of list and form schemas: `scripts` and `styles` URL lists, always arrays. | | `cabana.ToolbarAction` | One registered toolbar button in a list schema's `toolbarActions`: action name and localized label. | | `cabana.PartialView` / `cabana.PartialNode` | A rendered partial: a list of nodes, each an allowlisted element (`tag`, `attrs`, `children`) or a text node (`text`). | ## Configuration | Key | Default | Effect | |-----|---------|--------| | `admin.jwt.secret` | none | HMAC secret for admin tokens. Required as soon as any plugin registers an admin controller; boot fails without it. Set it through `SUMMER_ADMIN__JWT__SECRET` rather than a committed file. | | `admin.jwt.ttl` | `60` | Access token lifetime in minutes. | | `admin.jwt.refresh_ttl` | `20160` | Refresh window in minutes (14 days); also the lifetime of the admin cookie. | | `admin.jwt.blacklist_grace` | `0` | Seconds a token stays valid after it has been refreshed, for requests already in flight. | | `admin.password.bcrypt_cost` | `10` | Bcrypt cost for administrator passwords; values outside 4 to 31 fall back to 10. | | `admin.login.max_attempts` | `5` | Login attempts allowed per throttle window. | | `admin.login.decay_minutes` | `1` | Length of the login throttle window in minutes. | | `backend.uri` | `/backend` | Admin mount path. One or more lowercase path segments; boot fails on an invalid value. | | `backend.cookie_secure` | `true` | Set `false` to drop the cookie's Secure attribute for plain-HTTP development. Refused in the `production` environment. | | `app.url` | empty | Base URL used for the token issuer. | The backend user, role and token blacklist tables (`backend_users`, `backend_user_roles`, `backend_jwt_blacklist`) are created by `lagoon.BackendAdminMigrations`, which the `migrate` command runs. ## CLI commands Both commands are added to every application binary by the generated `main` and open the database themselves when the application has not. | Command | Arguments and flags | Effect | |---------|---------------------|--------| | `admin:create` | `--email`, `--password` (both required), `--login` (defaults to the lower-cased email), `--role `, `--superuser` | Creates an activated backend administrator. | | `admin:reset-password` | `` (login or email), `--password` | Sets a new password and revokes every token issued before the reset. | ```sh ./bin/acme admin:create --email admin@example.com --password '' --superuser ./bin/acme admin:reset-password admin@example.com --password '' ``` ## Dependencies - SummerCMS modules: [backpack](/docs/api/backpack.md), [boardwalk](/docs/api/boardwalk.md), [bonfire](/docs/api/bonfire.md), [bouncer](/docs/api/bouncer.md), [lagoon](/docs/api/lagoon.md), [pact](/docs/api/pact.md), [party](/docs/api/party.md), [phrasebook](/docs/api/phrasebook.md), [towel](/docs/api/towel.md). - Third-party: `gorm.io/gorm` (with `gorm.io/gorm/clause`), `github.com/goccy/go-yaml` (with its `ast` package), `golang.org/x/net/html` (with its `atom` package; parses rendered partials for the allowlist walk). - Standard library: `bytes`, `context`, `crypto/sha256`, `database/sql`, `encoding/hex`, `encoding/json`, `errors`, `fmt`, `html/template`, `io`, `io/fs`, `log/slog`, `math`, `net`, `net/http`, `path`, `reflect`, `regexp`, `sort`, `strconv`, `strings`, `time`. - Tests additionally use `github.com/testcontainers/testcontainers-go` and its `modules/postgres` package. ## Testing ```sh go test ./modules/cabana/... ``` The database-backed tests start a PostgreSQL container through testcontainers-go and need Docker. Run `go test -short ./modules/cabana/...` to skip them. YAML fixtures for the schema compiler live in `testdata/`. # compass Source: /docs/api/compass.html Layered YAML configuration with per-environment directories, `SUMMER_` environment overrides, embedded plugin defaults and dot-path access. `import "git.golem15.com/golem15/summercms/modules/compass"` ## Overview `compass` is the SummerCMS counterpart of WinterCMS's `config/*.php` files, per-environment config directories, `.env` support and `Config::get('app.name')`. It merges several layers into one tree built on koanf, and every key is read with a dot path such as `app.name`. Plugin defaults live under the bare plugin ID (`acme.blog.posts_per_page`) instead of WinterCMS's `acme.blog::posts_per_page`. The generated `main` of an application calls `compass.Load("config")` and hands the result to [backpack](/docs/api/backpack.md); [party](/docs/api/party.md) merges plugin defaults into it during activation. ## Features - A fixed layer order, lowest to highest precedence: 1. Plugin defaults added with `compass.Config.MergePlugin`: a plugin's `config.yaml` becomes `.`, any other `.yaml` becomes `..`. 2. `/*.yaml` (and `*.yml`): each file is a section named after the file, so `config/app.yaml` provides `app.*`. Files load in sorted order. 3. `/env//*.yaml`: per-environment sections with the same naming. 4. `SUMMER_` environment variables, including values from a `.env` file (see Configuration). 5. `/env//overrides.yaml`, the file written by `compass.Config.Persist`. 6. In-memory values stored with `compass.Config.Set`. - Typed getters with zero-value defaults: `compass.Config.String`, `compass.Config.Int`, `compass.Config.Bool`, plus `compass.Config.Lookup` and `compass.Config.Has` to tell a missing key from a zero value. - `compass.Config.LoadSection` unmarshals a whole subtree into a struct using `koanf` struct tags. - Runtime overrides: `compass.Config.Set` changes a value in memory, `compass.Config.Persist` saves all runtime values atomically to the environment's `overrides.yaml`, keeping the keys already saved there and replacing the saved value of any key set at runtime (directory mode 0700, file mode 0600, refusing any path outside the config directory), and `compass.Config.Reload` rereads every source and discards unsaved runtime values. - `compass.Config.Environment` reports the active environment name, which must consist of letters, digits, `-` and `_`. - Safe for concurrent reads and writes. ## Usage ```go package blog import ( "git.golem15.com/golem15/summercms/modules/compass" ) type mailSettings struct { Host string `koanf:"host"` Port int `koanf:"port"` } func loadConfig() (*compass.Config, error) { cfg, err := compass.Open(compass.Options{Dir: "config", Env: "development"}) if err != nil { return nil, err } name := cfg.String("app.name") perPage := cfg.Int("acme.blog.posts_per_page") _, _ = name, perPage var mail mailSettings if err := cfg.LoadSection("mail", &mail); err != nil { return nil, err } // Store a runtime override and write it to config/env/development/overrides.yaml. if err := cfg.Set("acme.blog.posts_per_page", 25); err != nil { return nil, err } return cfg, cfg.Persist() } ``` A matching configuration directory: ```yaml # config/app.yaml name: Acme url: http://localhost:8080 # config/env/development/app.yaml debug: true ``` ## API reference | Identifier | Description | |------------|-------------| | `compass.Config` | The merged configuration tree with dot-path access. | | `compass.Load` | Opens a config directory, taking the environment from `SUMMER_ENV`. | | `compass.Open` | Opens configuration with explicit `compass.Options`. | | `compass.Options` | Config directory, environment name and the environment variable list to read (defaults to the process environment). | | `compass.Config.String` / `compass.Config.Int` / `compass.Config.Bool` | Typed getters that return the zero value for a missing key. | | `compass.Config.Lookup` / `compass.Config.Has` | Raw value lookup and existence check. | | `compass.Config.LoadSection` | Unmarshals a subtree into a struct with `koanf` tags. | | `compass.Config.MergePlugin` | Adds a plugin's embedded default configuration under its plugin ID. | | `compass.Config.Set` / `compass.Config.Persist` / `compass.Config.Reload` | Runtime overrides, saving them to disk, and rebuilding from disk. | | `compass.Config.Environment` | Returns the active environment name. | ## Configuration `compass` reads the process environment (or `compass.Options.Environ` when it is set): | Variable | Default | Effect | |----------|---------|--------| | `SUMMER_ENV` | `production` | Selects the environment directory `config/env//`. An explicit `compass.Options.Env` wins over it. | | `SUMMER_
__` | none | Overrides a config key: the prefix is removed, `__` separates path segments and the name is lower-cased, so `SUMMER_DATABASE__DSN` sets `database.dsn` and `SUMMER_ADMIN__JWT__SECRET` sets `admin.jwt.secret`. | A `.env` file in the parent directory of the config directory (next to `config/`) supplies `KEY=VALUE` lines, with optional `export` prefixes and quotes, for variables that are not already set in the real environment. It never modifies the process environment. ## Dependencies - SummerCMS modules: none. - Third-party: `github.com/knadh/koanf/v2` with its `providers/file`, `providers/env/v2`, `providers/confmap` and `parsers/yaml` packages. - Standard library: `fmt`, `io/fs`, `os`, `path/filepath`, `sort`, `strings`, `sync`, `unicode`. ## Testing ```sh go test ./modules/compass/... ``` The tests use temporary directories and explicit environment lists and need no external services. # conga Source: /docs/api/conga.html Background jobs on River over the shared Postgres pool: transactional dispatch, a `summer_jobs` progress record, in-process or dedicated workers, and a wall-clock scheduler. `import "git.golem15.com/golem15/summercms/modules/conga"` ## Overview conga is the SummerCMS counterpart of the WinterCMS queue plus the apparatus job manager. Plugins describe background work as typed job functions wrapped by `conga.Job` and return them from `pact.HasJobs`, so plugin code never imports River. A caller dispatches a job with `conga.Manager.Dispatch` inside its own write transaction: the `summer_jobs` record row and the River job are written on the same `*sql.Tx`, so a rollback leaves neither behind. River only executes the work; the record row is what progress, outcome and cancellation reads use. Every River client runs on the one `*sql.DB` pool that `lagoon` opens. A worker started by `conga.StartWorker` uses `riverdatabasesql.NewWithPgxListener`: all queries go through the shared pool, and only Postgres `LISTEN` goes through a dedicated pgx pool with a single connection, so a job committed by any process wakes the worker immediately instead of waiting for the poll interval. The scheduler is the Go form of WinterCMS `registerSchedule`. Plugins declare recurring console commands through `pact.HasSchedule`; every worker turns them into River periodic jobs on wall-clock `conga.Daily` and `conga.Every` schedules in the `app.timezone` location. Each due run is a job on the `scheduled` queue that calls the command in-process through the app's `bonfire.Catalog`. ## Features - River-free job declarations: `conga.Job` turns `func(ctx context.Context, args T) error` into a `pact.Job`; `conga.OnQueue`, `conga.MaxAttempts` and `conga.Timeout` set per-job defaults. A `pact.Job` not built by `conga.Job` is rejected with `conga.ErrNotCongaJob`. - Transactional dispatch: `conga.Manager.Dispatch` inserts the `summer_jobs` row with `conga.StatusInProgress`, the principal's user id and admin flag, `progress_max` from `conga.DispatchOpts.Count` and JSON metadata, then enqueues the River job in the same transaction. It opens a transaction itself when the caller has none. - Plain enqueue: `conga.Manager.Enqueue` inserts a River job without a record row, inside the caller's transaction when there is one. - The record row: `conga.Record` maps `summer_jobs`; `conga.Status` holds the WinterCMS status values (`conga.StatusInQueue`, `conga.StatusInProgress`, `conga.StatusComplete`, `conga.StatusError`, `conga.StatusStopped`). `conga.JobID` gives a running job its own row id. - The WinterCMS job manager operations with the same semantics: `conga.Manager.StartJob`, `conga.Manager.UpdateJobState`, `conga.Manager.UpdateMetadata`, `conga.Manager.CompleteJob`, `conga.Manager.FailJob`, `conga.Manager.CheckIfCanceled` and `conga.Manager.GetMetadata`. Updates are raw column writes, so `updated_at` changes only on dispatch and `conga.Manager.StartJob`. - Cancellation in two parts: `conga.Manager.CancelJob` is the outside cancel (sets `is_canceled` and `conga.StatusStopped`, then cancels the River job, so a queued job never starts and a running job's context is cancelled); `conga.Manager.StopJob` is what a job calls on its own row after `conga.Manager.CheckIfCanceled` reports true (status only, the WinterCMS `cancelJob`). - Outcome rules in the worker: an error on an attempt before the last leaves the row in progress so River can retry; the final failed attempt, or a recovered panic on it, sets `conga.StatusError` with the error text under the metadata key `error`; an error on a row that was stopped or cancelled cancels the River job instead. A job that returns nil without completing its row leaves it as it is. Skipped work is recorded as complete with `{"skipped": true}` metadata. - Workers: `conga.StartWorker` registers every plugin job and starts one River client; `conga.WorkerOptions.Queues` limits it to some queues, and an unknown queue is `conga.ErrUnknownQueue` listing the known ones. `conga.Worker.Stop` stops it gracefully and cancels running jobs when its context ends. `conga.StartServeWorker` is the variant the `serve` command uses: it starts nothing when `queue.work_in_serve` is false. An app without jobs still gets a worker that starts and idles. - Scheduled commands: every worker carries one River periodic job per `pact.HasSchedule` entry, in plugin activation order then declaration order, with the id `[]:`. Only the elected leader enqueues. Each run is a `conga.ScheduledCommandArgs` job on `conga.QueueScheduled` with one attempt (an interrupted run is not retried; the next period runs normally) and unique by args within its cadence period, so a leader failover cannot double-enqueue a period. The worker runs a job only when its entry exists in the compiled schedule and its command and arguments match that entry exactly, so a forged `river_job` row cannot run an arbitrary command. An unregistered command, or an app with no published `bonfire.Catalog`, is logged at Warn (`schedule: command not registered; skipping`) and skipped without failing the worker or other entries. Command output is logged line by line at Info with a `command` attribute; failures are logged with the duration. - Wall-clock schedules: `conga.Daily` fires at the next `hh:mm` in its location and `conga.Every` at the next multiple of its interval since local midnight, so a restart never delays a daily run by up to a day the way `river.PeriodicInterval(24h)` would. On a DST day `conga.Daily` keeps the wall-clock time. A schedule entry with an empty command, a zero cadence, a daily time out of range, or an interval under one second or not dividing 24h fails the worker start with an error naming the plugin id and entry index. - Commands: `conga.RuntimeCommands` adds `queue:work`, `schedule:run` and `queue:clear` to the application binary. `schedule:run` runs a scheduler-only worker in its own process, and `schedule:run --once` runs the entries due in the current minute without River, for system cron. ## Usage A plugin declares a job: ```go package blog import ( "context" "git.golem15.com/golem15/summercms/modules/conga" "git.golem15.com/golem15/summercms/modules/pact" ) type ImportPostsArgs struct { File string `json:"file"` } func (ImportPostsArgs) Kind() string { return "acme_blog_import_posts" } func ImportPostsJob(m *conga.Manager) pact.Job { return conga.Job(func(ctx context.Context, args ImportPostsArgs) error { id, _ := conga.JobID(ctx) // ... import args.File ... return m.CompleteJob(ctx, id, nil) }, conga.OnQueue("imports")) } ``` and dispatches it inside the write that needs it: ```go func startImport(ctx context.Context, app *backpack.App, gdb *gorm.DB, file string) (uint, error) { m, err := conga.From(app) if err != nil { return 0, err } var id uint err = gdb.WithContext(ctx).Transaction(func(tx *gorm.DB) error { // ... write the import record ... id, err = m.Dispatch(ctx, tx, ImportPostsArgs{File: file}, conga.DispatchOpts{Label: "Import posts", Count: 100}) return err }) return id, err } ``` A long job reports progress and honours cancellation between items: ```go func importPosts(ctx context.Context, m *conga.Manager, files []string) error { id, _ := conga.JobID(ctx) if err := m.StartJob(ctx, id, len(files)); err != nil { return err } for i, f := range files { if canceled, err := m.CheckIfCanceled(ctx, id); err != nil || canceled { if err != nil { return err } return m.StopJob(ctx, id, nil) } // ... import f ... _ = f if err := m.UpdateJobState(ctx, id, i+1, nil); err != nil { return err } } return m.CompleteJob(ctx, id, map[string]any{"imported": len(files)}) } ``` A plugin schedules one of its registered commands; the worker runs it: ```go var _ pact.HasSchedule = (*Plugin)(nil) func (p *Plugin) Schedule() []pact.ScheduledCommand { return []pact.ScheduledCommand{ {Command: "blog:prune-drafts", Cadence: pact.Daily()}, {Command: "blog:sync-feed", Args: []string{"--quiet"}, Cadence: pact.Every(15 * time.Minute)}, } } ``` A worker runs in the same process or in a separate one: ```go w, err := conga.StartWorker(ctx, app, plugins, conga.WorkerOptions{}) if err != nil { return err } defer w.Stop(context.Background()) ``` ## API reference | Identifier | Description | |------------|-------------| | `conga.From` | Returns the app's `conga.Manager`, publishing one on first use. | | `conga.Manager` | App-scoped job manager: registration, dispatch and the `summer_jobs` record. | | `conga.Manager.Register` | Registers jobs built by `conga.Job`; closed while a worker runs (`conga.ErrRegistrationClosed`). | | `conga.Manager.Dispatch` | Writes the record row and enqueues the River job in one transaction; returns the row id. | | `conga.Manager.Enqueue` | Enqueues a River job without a record row. | | `conga.Manager.StartJob` | Sets progress to 0, `progress_max` to the total and `updated_at` to now. | | `conga.Manager.UpdateJobState` | Sets progress; replaces metadata when given. | | `conga.Manager.UpdateMetadata` | Replaces metadata. | | `conga.Manager.CompleteJob` | Sets `conga.StatusComplete` and progress to `progress_max`; replaces metadata when given. Skipped work passes `{"skipped": true}`. | | `conga.Manager.FailJob` | Sets `conga.StatusError`; replaces metadata when given. | | `conga.Manager.CancelJob` | Sets `is_canceled` and `conga.StatusStopped` and cancels the River job. | | `conga.Manager.StopJob` | Sets `conga.StatusStopped` only; called by a job on its own row. | | `conga.Manager.CheckIfCanceled` | Reports `is_canceled`. | | `conga.Manager.GetMetadata` | Decodes metadata; an empty or non-object value is an empty map. | | `conga.Manager.Get` | Reads one `conga.Record`. | | `conga.DispatchOpts` | Label, count, metadata, queue, delay and attempt limit of a dispatch. | | `conga.EnqueueOpts` | Queue, delay and attempt limit of an enqueue. | | `conga.Record` | The `summer_jobs` row model. | | `conga.Status` | Record status values, matching the WinterCMS job statuses. | | `conga.Job` | Wraps a typed job function as a `pact.Job` that conga can run on River. | | `conga.JobOption` | Per-job option: `conga.OnQueue`, `conga.MaxAttempts`, `conga.Timeout`. | | `conga.JobID` | Returns the record row id of the job running in a context. | | `conga.StartWorker` | Registers plugin jobs and starts a River worker client carrying the plugins' periodic schedule jobs. | | `conga.StartServeWorker` | The worker of the `serve` command; nil when `queue.work_in_serve` is false. | | `conga.WorkerOptions` | Selects the queues a worker runs. | | `conga.RuntimeCommands` | Returns the `queue:work`, `schedule:run` and `queue:clear` commands. | | `conga.Worker` | A running worker; `conga.Worker.Stop` stops it and `conga.Worker.Queues` lists its queues. | | `conga.Daily` | `river.PeriodicSchedule` firing at `Hour:Minute` every day in `Loc` (UTC when nil). | | `conga.Every` | `river.PeriodicSchedule` firing at every multiple of `Interval` since local midnight in `Loc` (UTC when nil). | | `conga.ScheduledCommandArgs` | Args of one scheduled run: compiled `Entry` id, `Command` and `Args`; kind `summer.scheduled_command`. | | `conga.QueueScheduled` | The `scheduled` queue that scheduled runs are inserted on. | | `conga.ErrNoDatabase` | The app has not published the shared database handles. | | `conga.ErrNotCongaJob` | A registered `pact.Job` was not built by `conga.Job`. | | `conga.ErrRegistrationClosed` | Registration was attempted while a worker runs. | | `conga.ErrUnknownQueue` | A worker was asked for a queue nothing names. | ## Configuration Keys are read from the compass config (`config/queue.yaml`, or `SUMMER_QUEUE__...` environment variables). | Key | Default | Controls | |-----|---------|----------| | `queue.work_in_serve` | `true` | Whether the `serve` command runs the job worker in its own process. Set `false` when a separate `queue:work` process runs the jobs. | | `queue.max_attempts` | `3` | Attempts per job before its record becomes `conga.StatusError`, unless the job or dispatch sets its own. | | `queue.job_timeout` | `300` | Per-attempt deadline, in seconds or as a duration string such as `5m`, unless the job sets `conga.Timeout`. | | `queue.queues.` | `default: 4`, `scheduled: 1` | Concurrent workers per queue. The worker runs these queues plus every queue a registered job names plus `default` and `scheduled`. | | `app.timezone` | `UTC` | IANA location of `pact.Daily`, `pact.DailyAt` and `pact.Every` schedules. An unknown name fails the worker start. | | `database.dsn` | none (required) | Also opens the worker's single-connection `LISTEN` pool. With PgBouncer, that connection must use session pooling or go straight to Postgres; transaction pooling cannot hold a `LISTEN`. | ```yaml work_in_serve: true max_attempts: 3 job_timeout: 300 queues: default: 4 imports: 1 ``` ## CLI commands `conga.RuntimeCommands` adds these commands to the application binary. They open the database through `lagoon.OpenFromApp`, so they need `database.dsn` and `app.key`; `schedule:run --once` opens it only when an entry is due. | Command | Arguments and flags | Description | |---------|---------------------|-------------| | `queue:work` | `--queue `, repeatable | Runs a job worker in the foreground on the named queues (default: every known queue) until SIGINT or SIGTERM, then stops it within 10 seconds. An unknown queue is an error that lists the known ones. | | `schedule:run` | `--once` (bare) | Without `--once`: runs a worker on the `scheduled` queue that carries every plugin's periodic jobs, prints `scheduler started` and runs until SIGINT or SIGTERM, then stops within 10 seconds. With `--once`: runs, in-process and without River, every entry due in the current minute of `app.timezone` (a daily entry at its hour and minute; `Every(d)` of a minute or less on every run, a longer one when the minutes since midnight are a multiple of `d`), printing `Running scheduled command: ` per entry or `No scheduled commands are ready to run.`. An unregistered command prints a warning and is skipped. The exit status is the first command error, after every due entry ran. There is no overlap lock: two runs in one minute run the due entries twice, as Laravel does. System cron: `* * * * * cd /app && ./bin/app schedule:run --once`. | | `queue:clear` | `[queue]` (default `default`) | Deletes the available, scheduled and retryable jobs of one queue in batches until none are left and prints `Cleared N jobs`. Running jobs are never touched. | ## Dependencies - SummerCMS modules: [backpack](/docs/api/backpack.md), [bonfire](/docs/api/bonfire.md) (commands and the `bonfire.Catalog` scheduled runs call), [bouncer](/docs/api/bouncer.md) (the dispatching principal), [lagoon](/docs/api/lagoon.md) (the shared pool, `lagoon.JobsTable` and the migrations that create River's schema and `summer_jobs`), [pact](/docs/api/pact.md), [party](/docs/api/party.md). - Third-party: `github.com/riverqueue/river` v0.47.0 with its `riverdriver/riverdatabasesql` and `rivertype` modules. River is the Postgres job queue the project stack names: it gives transactional inserts on the shared `*sql.DB`, retries, stuck-job rescue, leader election for periodic work and `LISTEN`/`NOTIFY` wake-ups. `github.com/jackc/pgx/v5/pgxpool` opens the listener pool; `gorm.io/gorm` writes the record rows. ## Testing ```sh go test ./modules/conga/... ``` The tests start a `postgres:16-alpine` container through testcontainers-go and migrate a fresh database per test with `lagoon.Migrate`, so they need a running Docker daemon. `TestListenPickupLatency` sets a 30-second poll interval and checks that a job committed by a separate client is picked up in under one second, while a poll-only worker does not pick it up within two. `TestScheduleRunsCommand` checks that an `Every(1s)` entry runs its command through a periodic job and that an unregistered command is skipped with a Warn log. `go test -short ./modules/conga/...` skips the database tests. # festival Source: /docs/api/festival.html Typed, synchronous event bus with listener priorities, payload collection and stop-when-handled dispatch. `import "git.golem15.com/golem15/summercms/modules/festival"` ## Overview `festival` is the SummerCMS counterpart of WinterCMS's `Event::listen` and `Event::fire`, the mechanism plugins use to extend each other without direct calls. Events are routed by their Go type rather than by a string name, so a listener registered for `*PostPublished` receives exactly that type, and a payload mismatch is a compile error. Each application owns one bus: [backpack](/docs/api/backpack.md) creates it in `backpack.New` and exposes it as `backpack.App.Events`, and plugins register listeners from their Boot step. ## Features - Listener registration with `festival.Bus.Listen` (priority 0) and `festival.Bus.ListenPriority`. Higher priorities run first; listeners with equal priority run in registration order. Every listener carries the ID of the plugin that owns it. - Three dispatch modes, all synchronous on the caller's goroutine: - `festival.Bus.Fire` runs every listener and returns the joined errors of all that failed (`errors.Join`). - `festival.Bus.Collect` runs every listener and, after each one, merges the event's `festival.Collectable.Collected` map into a single payload (later keys win). It returns the payload gathered so far together with the joined errors. - `festival.Bus.UntilHandled` stops at the first error or as soon as the event's `festival.Handleable.IsHandled` reports true, and returns whether the event was handled (WinterCMS's halting fire). - Panic isolation: a panicking listener is recovered and reported as an error that names its owner plugin, so one faulty plugin cannot take down the dispatch. - Safe for concurrent use: registration is locked and dispatch works on a snapshot of the listener list. - Value and pointer types are distinct event types; events that listeners modify (for `Collect` and `UntilHandled`) are usually pointers. ## Usage ```go package blog import ( "context" "git.golem15.com/golem15/summercms/modules/festival" ) // PostPublished is fired after a post goes live. Listeners add payload // entries and may mark the event handled. type PostPublished struct { PostID uint payload map[string]any handled bool } func (e *PostPublished) Collected() map[string]any { return e.payload } func (e *PostPublished) IsHandled() bool { return e.handled } func publish(ctx context.Context, bus *festival.Bus) (map[string]any, error) { bus.ListenPriority("acme.search", 10, func(ctx context.Context, e *PostPublished) error { if e.payload == nil { e.payload = map[string]any{} } e.payload["indexed"] = true return nil }) return bus.Collect(ctx, &PostPublished{PostID: 42}) } ``` The event type is inferred from the listener's parameter, so this listener only receives `*PostPublished` events. In a plugin, the bus is `app.Events` on the `backpack.App` passed to Boot; `festival.New` is for tests and standalone use. ## API reference | Identifier | Description | |------------|-------------| | `festival.Bus` | Application-owned, type-keyed event dispatcher. | | `festival.New` | Returns an empty bus. | | `festival.Bus.Listen` | Registers a listener for event type T at priority 0. | | `festival.Bus.ListenPriority` | Registers a listener for event type T at an explicit priority. | | `festival.Bus.Fire` | Runs every listener and joins their errors. | | `festival.Bus.Collect` | Runs every listener and merges the event's collected payload. | | `festival.Bus.UntilHandled` | Runs listeners until one handles the event or fails. | | `festival.Collectable` | Implemented by events that expose a mergeable payload for `Collect`. | | `festival.Handleable` | Implemented by events that can stop `UntilHandled`. | ## Dependencies - SummerCMS modules: none. - Third-party: none. - Standard library: `context`, `errors`, `fmt`, `reflect`, `sort`, `sync`. ## Testing ```sh go test ./modules/festival/... ``` The tests use in-process listeners and need no external services. # fetchguard Source: /docs/api/fetchguard.html Guarded outbound HTTPS fetcher that blocks private and reserved addresses and enforces host, size and timeout limits. `import "git.golem15.com/golem15/summercms/modules/fetchguard"` ## Overview `fetchguard` is the framework's server-side request forgery guard for fetching URLs that come from users or third parties, such as a remote image address. Every call takes a `fetchguard.Policy` that either restricts the target to an allow list of hosts or permits any public host; in both modes the dial-time check refuses private, loopback, link-local, carrier-grade NAT, documentation, multicast and other reserved IPv4 and IPv6 ranges, including IPv4 embedded in NAT64 and 6to4 addresses. Failures come back as a `fetchguard.Error` carrying one `fetchguard.Reason` from a closed set, so callers can map them onto stable API error codes. WinterCMS has no dedicated counterpart; plugins there typically used Guzzle with hand-written checks. ## Features - HTTPS only: any other scheme fails with `fetchguard.ReasonScheme`. - Two modes: `fetchguard.AllowHostsMode` (exact or dotted-suffix host match against `fetchguard.Policy.AllowHosts`) and `fetchguard.PublicOnlyMode` (any public host). - The private and reserved address check runs on the resolved IP at dial time, so DNS answers that point inside the network are refused (`fetchguard.ReasonPrivateIP`); environment proxies are ignored so the check sees the real target. - Redirects are never followed: a 3xx response is returned as a successful `fetchguard.Result`, and a caller that wants to follow the Location header calls `fetchguard.Fetch` again, which re-runs the guard. - The response body is capped at the policy's byte limit (`fetchguard.ReasonTooLarge` when exceeded), with a per-call timeout. - Limits left at zero in the policy fall back to config keys, then to framework defaults of 10 MiB and 10 seconds (`fetchguard.Defaults`, `fetchguard.DefaultsFromConfig`). - Typed failure reasons: `fetchguard.ReasonInvalidURL`, `fetchguard.ReasonScheme`, `fetchguard.ReasonUnresolvable`, `fetchguard.ReasonPrivateIP`, `fetchguard.ReasonNetworkError`, `fetchguard.ReasonTooLarge`. ## Usage ```go policy := fetchguard.Policy{ Mode: fetchguard.AllowHostsMode, AllowHosts: []string{"images.example.com"}, MaxBytes: 5 << 20, Timeout: 5 * time.Second, } res, err := fetchguard.Fetch(ctx, imageURL, policy, app.Config) if err != nil { var fe *fetchguard.Error if errors.As(err, &fe) && fe.Reason == fetchguard.ReasonPrivateIP { return errRejectedURL } return err } if res.StatusCode != http.StatusOK { return fmt.Errorf("image fetch: status %d", res.StatusCode) } image := res.Body ``` ## API reference | Identifier | Description | |------------|-------------| | `fetchguard.Fetch` | Validates the URL against the policy and performs the guarded HTTPS GET; a non-nil error is always a `fetchguard.Error`. | | `fetchguard.Policy` | Per-call settings: mode, allowed hosts, byte limit and timeout (zero means use the configured default). | | `fetchguard.Mode` | Selects `fetchguard.AllowHostsMode` or `fetchguard.PublicOnlyMode`. | | `fetchguard.Result` | Response body, Content-Type header value and status code of any completed response, including 3xx and non-2xx. | | `fetchguard.Error` | Failure carrying a `fetchguard.Reason` and the underlying error for logging. | | `fetchguard.Reason` | Closed set of failure reasons (`invalid_url`, `scheme`, `unresolvable`, `private_ip`, `network_error`, `too_large`). | | `fetchguard.Defaults` | Framework fallback limits: 10 MiB and 10 seconds. | | `fetchguard.DefaultsFromConfig` | Reads the limits from a `compass.Config`, falling back to `fetchguard.Defaults` for absent keys. | ## Configuration `fetchguard.Fetch` and `fetchguard.DefaultsFromConfig` read these keys from the `compass.Config` passed to them. They apply only when the policy leaves the matching limit at zero, and an explicitly configured zero or negative value is an error. | Key | Default | Controls | |-----|---------|----------| | `http.fetch.max_bytes` | `10485760` (10 MiB) | Maximum response body size in bytes. | | `http.fetch.timeout_seconds` | `10` | Dial and overall request timeout, in seconds. | ```yaml http: fetch: max_bytes: 5242880 timeout_seconds: 5 ``` ## Dependencies - SummerCMS modules: [compass](/docs/api/compass.md) (config lookup). - Third-party: none. - Standard library: `context`, `crypto/tls`, `errors`, `fmt`, `io`, `math`, `net`, `net/http`, `net/netip`, `net/url`, `strings`, `syscall`, `time`. ## Testing ```sh go test ./modules/fetchguard/... ``` The tests run against local `net/http/httptest` TLS servers and cover the address classifier directly; they need no external services. # flare Source: /docs/api/flare.html Web Push delivery with VAPID (RFC 8292) and aes128gcm payload encryption (RFC 8291) behind a small Pusher interface. `import "git.golem15.com/golem15/summercms/modules/flare"` ## Overview flare sends browser push notifications. Push is a separate channel from realtime: `lighthouse` publishes to clients that hold an open connection, while flare hands a message to the browser vendor's push service, which wakes the browser even when no page is open. The application owns the subscriptions. When a browser subscribes, the frontend posts its `PushSubscription` (the endpoint URL and the `p256dh` and `auth` keys) to the application, which stores it. flare never reads a database. Code that sends a push passes a `flare.Subscription` to a `flare.Pusher`, and operator tooling reads stored subscriptions through a `flare.SubscriptionSource` that the application publishes on the app. `flare.From` builds the app-scoped `flare.Service` from `push.*` on first use. Its `flare.Service.Pusher` is the VAPID driver, `flare.VAPIDPusher`, which talks to push services directly with the standard library: - The payload is encrypted for the subscriber with `flare.Encrypt`: an ephemeral P-256 key agreement (`crypto/ecdh`) with the subscription's `p256dh` key, mixed with its `auth` secret through HKDF-SHA-256 (`crypto/hkdf`), then one AES-128-GCM record in the `aes128gcm` content coding. The implementation reproduces the RFC 8291 Appendix A test vector byte for byte. - Every request carries `Authorization: vapid t=, k=` from `flare.VAPIDHeader`. The ES256 token's `aud` is the endpoint's origin, `exp` lies `flare.VAPIDTokenLifetime` (12 hours) ahead and `sub` is `push.subject`. Endpoints come from browsers, so they are untrusted URLs. The driver only sends to `https` endpoints whose host is in `push.allowed_hosts`, checks this before it opens a connection, and never follows a redirect. ## Features - `flare.Pusher` with one method, `Send(ctx, sub, payload, opts)`. `flare.SendOptions` sets the `TTL` (default `push.ttl`), `Urgency` and `Topic` headers. - The VAPID driver POSTs the encrypted body with `TTL`, `Content-Encoding: aes128gcm`, `Content-Type: application/octet-stream`, the optional `Urgency` and `Topic` and the VAPID `Authorization` header. A 2xx answer is success. 404 and 410 return `flare.ErrSubscriptionGone`, so the caller can delete the subscription. Any other status returns a `*flare.StatusError` with the code and without the response body. Requests time out after `flare.DefaultTimeout` (10 s). - Nothing is sent while `push.enabled` is false: `Send` returns `flare.ErrPushDisabled`. - Endpoint allowlist: `flare.HostAllowed` matches a host against `push.allowed_hosts`, where `*.example.com` matches any subdomain of `example.com` (not `example.com` itself). The defaults, `flare.DefaultAllowedHosts`, are Firebase Cloud Messaging, Mozilla autopush, Apple and Windows push. A refused endpoint returns `flare.ErrEndpointNotAllowed`, and the error names the host, never the endpoint path. - Payloads up to `flare.MaxPayloadSize` (3993 bytes, the RFC 8291 limit for a 4096-byte body). A larger one returns `flare.ErrPayloadTooLarge`. - VAPID keys: `flare.GenerateVAPIDKeys` returns a P-256 pair as unpadded base64url (`flare.PublicKeyLength`, 87 characters, and `flare.PrivateKeyLength`, 43 characters). `flare.ParseVAPIDKeys` accepts padded or unpadded input and checks that the public key belongs to the private key; a bad pair returns `flare.ErrInvalidVAPIDKeys`. A subject that is not `mailto:` or `https:` returns `flare.ErrInvalidSubject`. - The private key never reaches logs or formatted output: `flare.VAPIDKeys` and `flare.Config` redact it in `String`, `GoString` and (for the config) `LogValue`, and no error carries key material. ## Usage Send a push to one stored subscription: ```go svc, err := flare.From(app) if err != nil { return err } payload := []byte(`{"title":"New comment","body":"Someone replied to your post"}`) err = svc.Pusher().Send(ctx, flare.Subscription{ Endpoint: row.Endpoint, P256dh: row.P256dh, Auth: row.Auth, }, payload, flare.SendOptions{Urgency: "normal"}) if errors.Is(err, flare.ErrSubscriptionGone) { // The browser unsubscribed: delete the row. } ``` Publish a subscription source from a plugin's `Boot`, so operator commands can read the stored subscriptions of a user: ```go type blogSubscriptions struct{ db *gorm.DB } func (s blogSubscriptions) Subscriptions(ctx context.Context, userID uint) ([]flare.SubscriptionInfo, error) { var user models.User if err := s.db.WithContext(ctx).First(&user, userID).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, flare.ErrUserNotFound } return nil, err } var rows []models.PushSubscription if err := s.db.WithContext(ctx).Where("user_id = ?", userID).Find(&rows).Error; err != nil { return nil, err } out := make([]flare.SubscriptionInfo, 0, len(rows)) for _, r := range rows { out = append(out, flare.SubscriptionInfo{ Subscription: flare.Subscription{Endpoint: r.Endpoint, P256dh: r.P256dh, Auth: r.Auth}, ID: r.ID, UserAgent: r.UserAgent, SubscribedAt: &r.CreatedAt, }) } return out, nil } // In Boot: if err := app.Publish[flare.SubscriptionSource](blogSubscriptions{db: gdb}); err != nil { return err } ``` ## API reference | Identifier | Description | |------------|-------------| | `flare.From(app)` | The app's `*flare.Service`, built from `push.*` and published on first use. | | `flare.Service` | `Config`, `Enabled`, `Pusher`, `SetHTTPClient` (replace the driver's HTTP client, for example in tests) and `Logger`. | | `flare.Pusher` | `Send(ctx, sub, payload, opts) error`. | | `flare.VAPIDPusher`, `flare.NewVAPIDPusher(cfg, hc)` | The VAPID driver. `hc` may be nil; a given client is copied and never follows redirects. | | `flare.Subscription` | `Endpoint`, `P256dh`, `Auth`, as `PushSubscription.toJSON` returns them. | | `flare.SendOptions` | `TTL`, `Urgency`, `Topic`. | | `flare.SubscriptionSource`, `flare.SubscriptionInfo` | The application's subscription store: `Subscriptions(ctx, userID)` returns the subscriptions with `ID`, `UserAgent`, `SubscribedAt` and `LastUsedAt`. | | `flare.Config`, `flare.LoadConfig` | The `push.*` settings with their defaults; `Keys` returns the key pair. | | `flare.VAPIDKeys`, `flare.GenerateVAPIDKeys`, `flare.ParseVAPIDKeys` | VAPID key pairs as unpadded base64url. | | `flare.VAPIDHeader(endpoint, subject, keys, now)` | The RFC 8292 `Authorization` header value. | | `flare.Encrypt(payload, sub)` | The RFC 8291 `aes128gcm` request body. | | `flare.HostAllowed(host, allowed)`, `flare.DefaultAllowedHosts` | The endpoint host allowlist and its default. | | `flare.Commands(app)`, `flare.GenerateVAPIDKeysCommandName`, `flare.TestPushCommandName` | The `websockets:generate-vapid-keys` and `websockets:test-push` commands and their names. | | `flare.ErrPushDisabled`, `flare.ErrEndpointNotAllowed`, `flare.ErrSubscriptionGone`, `flare.ErrUserNotFound`, `flare.ErrPayloadTooLarge`, `flare.ErrInvalidVAPIDKeys`, `flare.ErrInvalidSubject`, `flare.StatusError` | Errors. | | `flare.ContentEncoding`, `flare.MaxPayloadSize`, `flare.DefaultTTL`, `flare.DefaultTimeout`, `flare.VAPIDTokenLifetime`, `flare.PublicKeyLength`, `flare.PrivateKeyLength` | Constants. | ## Configuration | Key | Default | Description | |-----|---------|-------------| | `push.enabled` | `false` | Nothing is sent while false. | | `push.public_key` | `""` | VAPID public key, base64url (87 characters unpadded). | | `push.private_key` | `""` | VAPID private key, base64url (43 characters). Keep it out of committed files; set `SUMMER_PUSH__PRIVATE_KEY`. | | `push.subject` | `""` | VAPID `sub` claim: a `mailto:` or `https:` contact URI. | | `push.ttl` | `2419200` | Default `TTL` header, in seconds or as a duration string. | | `push.allowed_hosts` | FCM, Mozilla autopush, `*.push.apple.com`, `*.notify.windows.com` | Push service hosts an endpoint may point at, as a list or a comma-separated string. | ## CLI commands `flare.Commands(app)` returns two commands for the application binary. An application adds them to the list its plugin returns from `Commands`. | Command | Description | |---------|-------------| | `websockets:generate-vapid-keys [--update] [--show-current]` | Shows the configured keys, truncated to the first 8 and last 4 characters with their length and a check mark when they decode to a valid pair. `--show-current` stops there. Otherwise it generates a new P-256 pair, validates its length and base64url alphabet and prints both keys. With `--update` the keys are saved through `compass.Config.Set` and `compass.Config.Persist` to `env//overrides.yaml` in the config directory (mode 0600; other keys in the file are kept). Without it, the command prints `SUMMER_PUSH__PUBLIC_KEY=…` and `SUMMER_PUSH__PRIVATE_KEY=…` lines to set by hand. | | `websockets:test-push [--show-config]` | Prints the push configuration: enabled, whether each key is set with its length (never the value), the subject and its format. It then reads the user's subscriptions from the published `flare.SubscriptionSource`, lists them (endpoint shortened to 60 characters, user agent, when subscribed and last used) and asks `Send test notification?` (default yes; a non-interactive run takes the default). It sends one encrypted test push to each subscription and reports each result. `--show-config` is accepted for compatibility; the configuration is always shown. | `websockets:test-push` exits 1 when no subscription source is published (`no subscription source registered`), when the user is unknown or has no subscriptions, when push is disabled (it lists the subscriptions but sends nothing), and when any send fails. The test payload is `{"title":" test","body":"This is a test push notification sent at HH:MM:SS","data":{"test":true,"timestamp":}}`. ## Dependencies - `backpack`, `bonfire` and `compass` from this repository. - `github.com/golang-jwt/jwt/v5` (the ES256 VAPID token). - Everything else is the standard library: `crypto/ecdh`, `crypto/ecdsa`, `crypto/hkdf`, `crypto/aes`, `crypto/cipher` and `net/http`. No Web Push library is used. ## Testing ```bash go test ./modules/flare/... ``` `TestRFC8291AppendixA` fixes the RFC's salt and application server key and compares the output with the RFC's published bytes. The send tests run an `httptest.NewTLSServer` push service with `127.0.0.1` in the allowlist; it verifies the VAPID token with the key from `k=` and decrypts the body as a browser would. Pass the test server's client to `flare.NewVAPIDPusher` or `flare.Service.SetHTTPClient` so it trusts the test certificate. # lagoon Source: /docs/api/lagoon.html Postgres data layer: the shared GORM connection, per-plugin migrations, model helpers and file attachments. `import "git.golem15.com/golem15/summercms/modules/lagoon"` `import "git.golem15.com/golem15/summercms/modules/lagoon/attach"` ## Overview `lagoon` is the WinterCMS models and migrations counterpart: it opens the one `*sql.DB` pool (pgx stdlib driver) that GORM and the rest of the application share, runs each plugin's gormigrate set in its own history table, and ports the Eloquent model conventions that WinterCMS plugins rely on, such as `$fillable`, `$hidden`, `$jsonable`, encrypted casts and Laravel-style validation rules. Its `attach` subpackage ports WinterCMS's `system_files` attachments, storing originals and lazily generated thumbnails in a gocloud.dev blob bucket. The application binary gets the migrate and key commands from `lagoon.RuntimeCommands`. ## Features - One shared pool: `lagoon.Open`, `lagoon.Use` and `lagoon.OpenFromApp` return a `*sql.DB` and a `*gorm.DB` built on that same pool; `lagoon.Publish` makes both available on the `backpack.App`. - Database-ready hooks: `lagoon.OnDatabase` runs a callback with the pool and GORM handle as soon as the database is published, immediately when it already is, otherwise when `lagoon.Publish` runs. Plugins register GORM callbacks through it from Boot, which runs before the `serve` command publishes the database. - After-commit work: `lagoon.Transaction` runs a function in a transaction and then the callbacks registered with `lagoon.AfterCommit`, in order, only after the commit succeeds; a nested `lagoon.Transaction` is a savepoint whose callbacks are dropped with it when it fails. A nested `lagoon.Transaction` must be given the outer transaction's handle: given a root handle it returns an error without running its function, rather than open an independent transaction whose callbacks would wait on the outer one. A single-statement write for which GORM opens its own implicit transaction runs its callbacks from `lagoon:after_commit` once GORM commits, and never when the write fails. A callback registered inside a foreign plain GORM transaction is unsafe because Lagoon cannot observe its commit, so `lagoon.AfterCommit` warns and skips it. Outside a transaction, callbacks run immediately. The handle a supported callback receives always has an empty statement on the connection its work belongs to. A panicking callback is logged and never turns a committed write into an error. - Per-plugin migrations: `lagoon.Migrate` runs the framework's `system_files` set (`attach.Migrations`), backend admin identity set (`lagoon.BackendAdminMigrations`) and job-queue set (`lagoon.QueueMigrations`: River's schema pinned at `lagoon.RiverSchemaVersion`, then the `lagoon.JobsTable` record table, under the `lagoon.QueueHistoryID` history), then every `pact.HasMigrations` set in plugin activation order, each in its own `summer_migrations_` history table (`lagoon.HistoryTableName`). `lagoon.RollbackLast` and `lagoon.Status` cover rollback and history. - Mass assignment: `lagoon.Fill` copies only allow-listed keys onto a model by GORM column name and silently drops the rest, logging each dropped key once outside production. A `json.Number` (from a decoder using `UseNumber`) fills integer, unsigned and float fields. A value that does not fit its column (a fraction, an exponent or an overflow for an integer field, or a value of the wrong type) is a `lagoon.FillTypeError` naming the key, so a caller can answer it as a validation failure on that field. `lagoon.HasFillable` and `lagoon.HasHidden` are the Go forms of `$fillable` and `$hidden`. - Validation: `lagoon.Validate` accepts Laravel-style rule strings (`required`, `nullable`, `integer`, `numeric`, `between`, `min`, `max`, `in`, `unique`, `boolean`, `email`, `confirmed`, `different`, `mimes`) and returns a field-to-messages map, translated through phrasebook when a translator is given. Unknown rule tokens are an error. - Safe ordering: `lagoon.OrderBy` appends an ORDER BY only for an allow-listed column and an `asc` or `desc` direction, and `lagoon.Collate` adds a validated `COLLATE` clause for language-specific text order (for example the ICU collation `pl-x-icu`); lagoon puts no requirement on the database's default locale. - Pagination: `lagoon.Paginate` builds a `lagoon.Page` with `data` and `meta` (`current_page`, `last_page`, `per_page`, `total`). - Column types: `lagoon.Encrypted` stores AES-256-GCM ciphertext under a key derived from `app.key`, decrypts with previous keys during rotation, and always redacts itself in JSON and string output; `lagoon.Jsonable` stores JSON as TEXT and keeps SQL NULL distinct from an empty value. - Lifecycle and relations: hook interfaces matching GORM's native method names (`lagoon.HasBeforeCreate`, `lagoon.HasBeforeSave`, `lagoon.HasBeforeDelete`, `lagoon.HasAfterDelete`) plus `lagoon.HasBeforeValidate`; `lagoon.WithSoftDeleteCascade` runs a cascade inside the parent delete; `lagoon.RegisterJoinTable` wires pivot models with business columns. - Imports from Laravel: `lagoon.DecryptLaravelPayload` decrypts Laravel `encrypted` payloads with the old application key, for one-off data imports. - Attachments (`attach`): the `attach.File` model for `system_files` rows, WinterCMS-compatible partitioned storage keys (`attach.BlobKey`, `attach.PartitionDirectory`), on-demand thumbnails through `attach.File.Thumb`, static serving with an optional `is_public` gate (`attach.StaticHandlerPublic`), and a two-phase delete that removes blobs only after the database transaction commits (`attach.DeleteForOwner`, `attach.DeleteKeys`). ## Usage Open the shared pool once at boot and publish it, so plugins and handlers reuse the same handles: ```go sqlDB, gdb, err := lagoon.OpenFromApp(ctx, app) if err != nil { return err } if err := lagoon.Publish(app, sqlDB, gdb); err != nil { return err } ``` A model uses the column types and helpers directly: ```go type Post struct { ID uint `gorm:"column:id;primaryKey"` Title string `gorm:"column:title"` Tags lagoon.Jsonable[[]string] `gorm:"column:tags"` APIToken lagoon.Encrypted `gorm:"column:api_token"` } func (Post) Fillable() []string { return []string{"title"} } func createPost(ctx context.Context, gdb *gorm.DB, tr *phrasebook.Translator, input map[string]any) (map[string][]string, error) { var post Post rules := map[string]string{"title": "required|max:255|unique:posts"} if errs, err := lagoon.Validate(ctx, gdb, &post, rules, input, tr); err != nil || errs != nil { return errs, err } if err := lagoon.Fill(&post, post.Fillable(), input, false); err != nil { return nil, err } post.APIToken = lagoon.NewEncrypted("change-me") return nil, gdb.Create(&post).Error } ``` A plugin registers its GORM callbacks from Boot through `lagoon.OnDatabase`, so they are installed whenever the database is published, and defers side effects until the write commits: ```go func (p *Plugin) Boot(app *backpack.App) error { return lagoon.OnDatabase(app, func(_ *sql.DB, gdb *gorm.DB) error { return gdb.Callback().Create().After("gorm:after_create").Register("acme:post_created", func(db *gorm.DB) { if db.Error != nil { return } lagoon.AfterCommit(db.Statement.Context, db, func(ctx context.Context, db *gorm.DB) { // runs only once the insert is committed }) }) }) } func publish(ctx context.Context, gdb *gorm.DB, post *Post) error { return lagoon.Transaction(ctx, gdb, func(ctx context.Context, tx *gorm.DB) error { return tx.Create(post).Error // acme:post_created work waits for this commit }) } ``` A plugin ships its schema as an ordered gormigrate set; `migrate` runs it after the framework sets: ```go func (p *Plugin) Migrations() []*gormigrate.Migration { return []*gormigrate.Migration{{ ID: "202601010001_create_posts", Migrate: func(tx *gorm.DB) error { return tx.Exec(`CREATE TABLE posts (id SERIAL PRIMARY KEY, title TEXT NOT NULL, tags TEXT, api_token TEXT)`).Error }, Rollback: func(tx *gorm.DB) error { return tx.Exec(`DROP TABLE IF EXISTS posts`).Error }, }} } ``` ## API reference | Identifier | Description | |------------|-------------| | `lagoon.OpenFromApp` | Opens the shared pool from `database.dsn` and publishes the `app.key` encryption keys. | | `lagoon.Open` | Opens and pings a DSN and returns the pool plus a GORM handle on it. | | `lagoon.Use` | Returns a GORM handle on an existing pool after a ping. | | `lagoon.Publish` | Stores the pool and GORM handle on the `backpack.App`, then runs the callbacks queued by `lagoon.OnDatabase`. | | `lagoon.OnDatabase` | Runs a callback with the pool and GORM handle once the database is published. | | `lagoon.Transaction` | Runs a function in a transaction (a savepoint when nested) and its `lagoon.AfterCommit` callbacks after the commit. | | `lagoon.AfterCommit` | Registers work to run after the surrounding transaction commits. | | `lagoon.AfterCommitCallback` | Name of the GORM callback, `lagoon:after_commit`, that runs single-statement after-commit work. | | `lagoon.DSN` | Reads `database.dsn` from config. | | `lagoon.Migrate` | Runs framework and plugin migrations in order. | | `lagoon.RollbackLast` | Rolls back the last migration of one plugin. | | `lagoon.Status` | Lists applied migration IDs per plugin as `lagoon.StatusRow` values. | | `lagoon.HistoryTableName` | Returns the gormigrate history table for a plugin ID. | | `lagoon.BackendAdminMigrations` | Creates the backend user, role and admin token blacklist tables and seeds the system roles. | | `lagoon.QueueMigrations` | Migrates River's schema to `lagoon.RiverSchemaVersion` and creates the `summer_jobs` job record table. | | `lagoon.QueueHistoryID` | History id of the job-queue set, `summercms.conga`. | | `lagoon.JobsTable` | Name of the job record table, `summer_jobs`. | | `lagoon.RiverSchemaVersion` | The pinned River schema version, 7. | | `lagoon.RuntimeCommands` | Returns the migrate, migrate:rollback, migrate:status and key:generate commands. | | `lagoon.KeyGenerateCommand` | Returns the key:generate command on its own. | | `lagoon.LoadAppKey` | Decodes `app.key` and `app.previous_keys`. | | `lagoon.PublishEncryptionKeys` | Installs the keys used by `lagoon.Encrypted` columns. | | `lagoon.Encrypted` | Encrypted text column; `lagoon.Encrypted.Reveal` is the only plaintext accessor. | | `lagoon.Jsonable` | Generic JSON-as-TEXT column with NULL tracking. | | `lagoon.Fill` | Allow-listed mass assignment by column name. | | `lagoon.FillTypeError` | Returned by `lagoon.Fill` when a requested value does not fit its column; `Key` names the column. | | `lagoon.Validate` | Laravel-style rule validation with a `unique` database check. | | `lagoon.OrderBy` | Allow-listed ORDER BY, with an optional `lagoon.Collate`. | | `lagoon.Collate` | Order option that sorts the column with a named PostgreSQL collation; the name is validated and quoted. | | `lagoon.OrderOption` | Option type accepted by `lagoon.OrderBy`. | | `lagoon.Paginate` | Builds a `lagoon.Page` with `lagoon.PageMeta`. | | `lagoon.WithSoftDeleteCascade` | Runs a cascade inside the parent delete transaction. | | `lagoon.RegisterJoinTable` | Registers a custom pivot model for a many-to-many field. | | `lagoon.DecryptLaravelPayload` | Decrypts a Laravel AES-256-CBC payload for data imports. | | `attach.File` | The `system_files` row model. | | `attach.Owner` | Implemented by models that own attachments; returns the stored morph type name. | | `attach.OpenBucket` | Opens the uploads bucket from config. | | `attach.Publish` | Stores the bucket on the `backpack.App`. | | `attach.StaticHandler` | Serves stored files and thumbnails under a URL prefix. | | `attach.StaticHandlerPublic` | `attach.StaticHandler` plus a 404 for rows that are not public. | | `attach.DeleteForOwner` | Deletes an owner's attachment rows in a transaction and reports their blob keys. | | `attach.DeleteKeys` | Deletes blobs, including thumbnails, after the transaction commits. | | `attach.Migrations` | Creates the `system_files` table. | ## Configuration Keys are read from the compass config; the `SUMMER_` environment overlay maps a double underscore to a dot, for example `SUMMER_DATABASE__DSN` to `database.dsn`. | Key | Default | Controls | |-----|---------|----------| | `database.dsn` | none (required) | Postgres connection string used by `lagoon.OpenFromApp` and every database command. | | `app.key` | none (required) | Base64 encoding of 32 random bytes; the source of the `lagoon.Encrypted` column key. Generate one with `key:generate`. | | `app.previous_keys` | empty | List of earlier base64 keys still accepted when decrypting, for key rotation. | | `storage.uploads.bucket_url` | none (required by `attach.OpenBucket`) | Uploads bucket URL, `file://` or `mem://`. | | `storage.uploads.public_path_prefix` | `/storage/uploads` | URL prefix used when building public file and thumbnail URLs. | ```yaml database: dsn: postgres://acme:@127.0.0.1:5432/acme?sslmode=disable app: key: storage: uploads: bucket_url: file:///var/lib/acme/uploads ``` ## CLI commands `lagoon.RuntimeCommands` adds these commands to the application binary. All of them except key:generate open the database through `lagoon.OpenFromApp`, so they need `database.dsn` and `app.key`. | Command | Flags | Description | |---------|-------|-------------| | `migrate` | none | Runs the framework migrations, then each plugin's migrations in dependency order. | | `migrate:rollback` | `--plugin ` | Rolls back the last migration of the given plugin; without the flag, of the last activated plugin that has migrations. | | `migrate:status` | none | Prints a table of plugin, history table and applied migration IDs. | | `key:generate` | none | Prints a fresh base64 32-byte key for `app.key`; writes nothing. | ## Dependencies - SummerCMS modules: [backpack](/docs/api/backpack.md), [bonfire](/docs/api/bonfire.md), [compass](/docs/api/compass.md), [pact](/docs/api/pact.md), [party](/docs/api/party.md), [phrasebook](/docs/api/phrasebook.md) (validation messages). - Third-party: `gorm.io/gorm`, `gorm.io/driver/postgres`, `github.com/jackc/pgx/v5` (stdlib driver), `github.com/go-gormigrate/gormigrate/v2`, `github.com/go-playground/validator/v10`, `github.com/riverqueue/river` (its `rivermigrate` and `riverdriver/riverdatabasesql` packages, for the River schema in `lagoon.QueueMigrations`). - Third-party, `attach` only: `gocloud.dev/blob` (file and memory drivers), `github.com/disintegration/imaging` (thumbnails). - Standard library: `database/sql`, `crypto/aes`, `crypto/cipher`, `crypto/hkdf`, `log/slog`, `image`, among others. ## Testing ```sh go test ./modules/lagoon/... ``` The database tests in `lagoon` and `lagoon/attach` start a `postgres:16-alpine` container through testcontainers-go, so they need a running Docker daemon. They are skipped by `go test -short ./modules/lagoon/...`, which runs only the unit tests. # lighthouse Source: /docs/api/lighthouse.html Transport-neutral realtime: a publisher interface with pluggable drivers, subscribe-time channel authorization, and model broadcasts enqueued in the write transaction. `import "git.golem15.com/golem15/summercms/modules/lighthouse"` `import _ "git.golem15.com/golem15/summercms/modules/lighthouse/centrifugo"` ## Overview lighthouse is the SummerCMS counterpart of the WinterCMS websockets plugin. The package itself knows no transport. It owns the interfaces application code writes against, and a driver package supplies the transport. A driver registers itself from its `init` function, the way `database/sql` drivers do, and the application picks one with `realtime.driver`. `lighthouse.From` builds the app-scoped `lighthouse.Service` on first use and publishes it on the app. The service holds the selected driver, the application's user lookup, and the broadcast settings. A driver may need HTTP endpoints, such as a token route for signed-in users or a callback the realtime server calls. It declares them as `lighthouse.Route` values, each tagged with a `lighthouse.Surface`. The application mounts them once with `lighthouse.Mount` and decides the guard, group and rate-limit bucket per surface. Switching drivers never edits the application's route file. Channel authorization is transport-neutral too. Plugins register a `lighthouse.Authorizer` per channel namespace on the service's `lighthouse.Registry`. The driver's subscribe endpoint asks the authorizer of the channel's namespace on every subscribe, so a user who loses access is denied the next time the client subscribes. Nothing is cached. Model broadcasts follow the WinterCMS `BroadcastableModel` trait with one change. A create, update or delete of a broadcastable model enqueues a River job (through `conga`) inside the write's own transaction, so nothing is published for a write that rolls back. The job publishes after commit, with one attempt and best effort: a failed publish is logged and never touches the write. Suppression is per model type and scoped to a context, and `lighthouse.Service.Emit` publishes one explicit summary event instead. The `centrifugo` sub-package is the Centrifugo driver. It has a hand-rolled `net/http` client for the Centrifugo HTTP API, a token issuer with the claims of the WinterCMS `JwtTokenGenerator`, the token route handler, and the subscribe proxy handler. ## Features - Driver selection by `realtime.driver`. The built-in drivers are `null` (the default; it discards everything), `log` (logs channel names and the event, never the payload) and `memory` (`lighthouse.MemoryDriver` records every `lighthouse.Publication` for tests). An unknown name is a boot error that lists the registered drivers. - Third-party drivers: `lighthouse.RegisterDriver` with a `lighthouse.DriverFactory`. A duplicate name panics at init. - The `lighthouse.Publisher` interface (`Publish` for one channel, `Broadcast` for several) and the `lighthouse.Driver` interface, which adds `Name` and `Routes`. - Route mounting by surface: `lighthouse.UserAuth`, `lighthouse.ServerToServer` and `lighthouse.Public`. `lighthouse.Mount` puts user and public routes in `Group` and server-to-server routes in `GroupRaw`, each with the surface middleware followed by `lighthouse.Surfaces.Middleware`. A `lighthouse.UserAuth` route with no user middleware is refused, so a token route can never be mounted without a guard. Every route is validated before any is registered. - Users and actors: the application installs a `lighthouse.UserLookup` with `lighthouse.Service.SetUserLookup`. `lighthouse.Service.User` loads a `lighthouse.User` (id and display name). `lighthouse.Service.Actor` returns the `lighthouse.Actor` of a request, and `lighthouse.SystemActor` when there is no signed-in user or the principal is a backend admin. - Channel rules. A channel is `namespace:entity:id`, optionally prefixed once with `presence:`. - `lighthouse.ParseChannel` returns the namespace. It returns "" for a `presence:presence:` prefix or for more than three segments. The lookup is byte-exact and case-sensitive. - `lighthouse.ChannelID` returns segment 1 converted with PHP's `(int)` cast (`lighthouse.PHPInt`): `5abc` is 5, `abc` is 0, and out-of-range values saturate. - `lighthouse.FormatChannels` lowercases channel names and applies the broadcast namespace prefix. - Authorizer registry: `lighthouse.Registry` (from `lighthouse.Service.Registry`) maps namespaces to a `lighthouse.Authorizer` or `lighthouse.AuthorizerFunc`. Registering an empty namespace, a namespace that contains `:`, a nil authorizer or a namespace twice is an error. `lighthouse.Registry.Namespaces` is sorted. An authorizer returns `lighthouse.Allowed` (optionally with info, capabilities and overrides) or `lighthouse.Denied` with an internal reason that only reaches the logs. It reads the realtime client id with `lighthouse.ClientID`. - Model broadcasts. A model broadcasts when its pointer type implements `lighthouse.Broadcastable` (`BroadcastChannels(ctx, tx)`), or when a `lighthouse.Binding` is registered for it with `lighthouse.Bind`. A binding keeps payload code out of the model package. Optional overrides: - `lighthouse.BroadcastPayloader` or `Binding.Payload` replaces the default payload `{"model":…,"actor":…,"timestamp":"…+00:00","ttl":60}`. - `lighthouse.BroadcastAliaser` or `Binding.Alias` replaces the alias. - `lighthouse.BroadcastFilter` or `Binding.ShouldBroadcast` can veto an action. - `lighthouse.BroadcastTTLer` or `Binding.TTL` replaces the ttl. The event name is `{action}.{alias}` lowercased: `lighthouse.ActionCreated`, `lighthouse.ActionUpdated` or `lighthouse.ActionDeleted`, then an alias that defaults to `.` (the Go package name, or the parent directory of a `models` package, and the type name). The payload builder receives a `lighthouse.Event` with the action, the `lighthouse.Actor`, the timestamp and the ttl. A soft delete counts as a delete. A delete's channels and payload are computed from a fresh read of the row before it is deleted, so deleting a model that holds only its id still broadcasts. An empty channel list means no broadcast. - Transactional delivery. GORM callbacks (`lighthouse.CallbackAfterCreate`, `lighthouse.CallbackAfterUpdate`, `lighthouse.CallbackSnapshot` and `lighthouse.CallbackAfterDelete`) are installed through `lagoon.OnDatabase`. The after-write callbacks run after the model's own after hook and before GORM commits the transaction it opens for a single-statement write, so they enqueue a `lighthouse.BroadcastArgs` job on the write's `*sql.Tx` in every case (an explicit transaction or a single `Create`, `Save` or `Delete`), on the `realtime.broadcast_queue` queue with MaxAttempts 1 and the `realtime.broadcast_timeout` timeout. Channel and payload queries and the enqueue run inside a savepoint, so a failure (also one a channel or payload function swallows) is rolled back to it, logged at Warn with channels and event (never the payload), and the write goes on. A write with a zero primary key, such as `Model(&T{}).Where(…).Updates(…)`, is not broadcast; bulk paths suppress and emit instead. The null driver, or a driver whose `Enabled` reports false (Centrifugo without an API key), gets no jobs. - The broadcast job lowercases the channels and adds the `realtime.broadcast_namespace` prefix unless a channel already has it. It then publishes to one channel or broadcasts to several. A failure is logged as `realtime: broadcast failed` and is not retried. Delivery order across separate jobs is not guaranteed. The payload travels inside the job as a JSON string, so its key order survives Postgres JSONB. - Suppression: `lighthouse.WithoutBroadcasting` silences one model type for writes made with the context it hands to its function. Other types still broadcast, and a write through an outer context is not suppressed. `lighthouse.Service.Emit` enqueues one `lighthouse.Broadcast` on the caller's transaction and returns its error. Together they turn N row events into one summary event. - Centrifugo driver (`centrifugo.Driver`, driver name `centrifugo`): - `centrifugo.Client` POSTs `publish`, `broadcast`, `presence` and `unsubscribe` calls with `Authorization: apikey ` and a 5 s timeout. The publish body is `{"channel":…,"data":{"event":…,"payload":…,"timestamp":"…+00:00"}}`, with an empty payload sent as `[]`. Any 2xx status counts as success. With an empty API key nothing is sent and the call returns `centrifugo.ErrNotConfigured`. The key never appears in logs or errors. - `centrifugo.TokenIssuer` signs HS256 tokens with five generators, the same as the WinterCMS generator: `ForUser` (claims `sub`, `exp`, `info` with only `name`), `Subscription`, `Anonymous` (`sub` "" and a 5-minute lifetime), `ForIdentifier` (an empty `info` is encoded as `[]`) and `SubscriptionForIdentifier`. It refuses to sign with an empty secret. - `centrifugo.TokenHandler` serves the token route. It answers 401 `{"error":"Unauthorized"}` when no user is signed in, 503 `{"error":"WebSocket not configured"}` when the token secret is empty, and otherwise 200 `{"token":"…"}`. It sends `Cache-Control: no-cache, private` and no trailing newline. - `centrifugo.ProxyHandler` is the subscribe proxy endpoint. Centrifugo reads a non-200 status as an internal error, so every answer is HTTP 200. The checks run in this order: 1. `X-Centrifugo-Secret` must equal `realtime.centrifugo.proxy_secret`, compared in constant time. An empty configured secret denies every subscribe. 2. An empty or `"0"` user denies. The user may arrive as a JSON string or number; any other type counts as empty. 3. A missing channel denies. Centrifugo always sends one. 4. The channel's namespace must have a registered authorizer. 5. The authorizer receives the user id and the full original channel. An allow answers `{"result":{"info":…}}`, with an empty info encoded as `[]`. A `presence:` channel also gets `allow` (the authorizer's capabilities, or `["prs"]`) and `override`. The override starts from the defaults `presence` and `join_leave` true and `force_push_join_leave` false, then applies the authorizer's overrides. Every deny answers `{"error":{"code":403,"message":"Access denied"}}` and logs `Subscription denied` at Warn with the reason, and never either secret. The request body is capped at 64 KiB. ## Usage An application selects the driver in `config/realtime.yaml`: ```yaml driver: centrifugo centrifugo: token_secret: "" # set with SUMMER_REALTIME__CENTRIFUGO__TOKEN_SECRET ``` A plugin imports the driver package for its side effect, builds the service at Boot, installs a user lookup and registers its channel authorizers: ```go package acme import ( "context" "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/lighthouse" _ "git.golem15.com/golem15/summercms/modules/lighthouse/centrifugo" ) func (p *Plugin) Boot(app *backpack.App) error { svc, err := lighthouse.From(app) if err != nil { return err } p.realtime = svc svc.SetUserLookup(func(ctx context.Context, id uint) (lighthouse.User, bool, error) { return lookupAcmeUser(ctx, id) // the application's own user model }) // Allow room:{id} to members only; re-checked on every subscribe. return svc.Registry().Register("room", lighthouse.AuthorizerFunc( func(ctx context.Context, userID uint, channel string) lighthouse.Result { if isRoomMember(ctx, userID, lighthouse.ChannelID(channel)) { return lighthouse.Allowed(nil) } return lighthouse.Denied("not a room member") })) } ``` and mounts the driver's routes once: ```go func (p *Plugin) Routes(r pact.Router) error { return lighthouse.Mount(r, p.realtime.Driver(), lighthouse.Surfaces{ UserAuth: surf.Use("jwt.auth"), ServerToServer: surf.Use(), Middleware: surf.Use("throttle:acme-realtime"), }) } ``` A model package stays free of realtime code; the plugin binds the model at Boot: ```go err := lighthouse.Bind[models.Post](svc, lighthouse.Binding[models.Post]{ Alias: "blog.post", Channels: func(ctx context.Context, tx *gorm.DB, p *models.Post) ([]string, error) { return []string{"blog:" + strconv.FormatUint(uint64(p.BlogID), 10)}, nil }, }) ``` A bulk import suppresses the per-row events and publishes one summary after commit: ```go err := lighthouse.WithoutBroadcasting[models.Post](ctx, func(ctx context.Context) error { return lagoon.Transaction(ctx, gdb, func(ctx context.Context, tx *gorm.DB) error { for _, p := range posts { if err := tx.Create(&p).Error; err != nil { return err } } return svc.Emit(ctx, tx, lighthouse.Broadcast{ Channels: []string{"blog:7"}, Event: "blog.bulk_updated", Payload: struct { Reason string `json:"reason"` Count int `json:"count"` }{"import", len(posts)}, }) }) }) ``` Tests select the memory driver and read what was published: ```go mem := svc.Driver().(*lighthouse.MemoryDriver) for _, pub := range mem.Publications() { fmt.Println(pub.Method, pub.Channels, pub.Event) } ``` ## API reference ### lighthouse | Identifier | Description | |------------|-------------| | `lighthouse.From(app)` | The app's `*lighthouse.Service`, built and published on first use. | | `lighthouse.Service` | The realtime service: `Driver`, `Registry`, `Logger`, `Namespace`, `Queue`, `Timeout`, `SetUserLookup`, `User`, `Actor`. | | `lighthouse.Publisher` | `Publish(ctx, channel, event, payload)` and `Broadcast(ctx, channels, event, payload)`. | | `lighthouse.Driver` | `lighthouse.Publisher` plus `Name()` and `Routes()`. | | `lighthouse.DriverFactory` | `func(app, svc) (lighthouse.Driver, error)`. | | `lighthouse.RegisterDriver(name, factory)` | Registers a driver from an `init` function. | | `lighthouse.MemoryDriver`, `lighthouse.NewMemoryDriver`, `lighthouse.Publication` | The recording driver and its records (`Method`, `Channels`, `Event`, `Payload`, `Timestamp`). | | `lighthouse.Route` | `Name`, `Method`, `Path`, `Surface`, `Handler`. | | `lighthouse.Surface`, `lighthouse.UserAuth`, `lighthouse.ServerToServer`, `lighthouse.Public` | Who calls a route. | | `lighthouse.Surfaces` | Application middleware per surface plus `Middleware` for every route. | | `lighthouse.Mount(r, driver, surfaces)` | Registers a driver's routes. | | `lighthouse.Authorizer`, `lighthouse.AuthorizerFunc` | `Authorize(ctx, userID, channel) lighthouse.Result`. | | `lighthouse.Result`, `lighthouse.Allowed`, `lighthouse.Denied` | A subscribe decision: `Allowed`, `Info`, `Capabilities`, `Overrides` and `Reason()`. | | `lighthouse.Registry`, `lighthouse.NewRegistry` | Namespace to authorizer map: `Register`, `Get`, `Namespaces`. | | `lighthouse.ParseChannel`, `lighthouse.ChannelID`, `lighthouse.PHPInt`, `lighthouse.FormatChannels` | Channel rules. | | `lighthouse.WithClientID`, `lighthouse.ClientID` | The realtime client id of a subscribe request, carried in the context. | | `lighthouse.Action`, `lighthouse.ActionCreated`, `lighthouse.ActionUpdated`, `lighthouse.ActionDeleted` | Broadcast actions. | | `lighthouse.Event` | `Action`, `Actor`, `Timestamp`, `TTL` of a change. | | `lighthouse.Broadcastable`, `lighthouse.BroadcastPayloader`, `lighthouse.BroadcastAliaser`, `lighthouse.BroadcastFilter`, `lighthouse.BroadcastTTLer` | The model-method broadcast contract. | | `lighthouse.Binding`, `lighthouse.Bind` | Broadcast contract registered from outside the model package. | | `lighthouse.WithoutBroadcasting` | Suppresses one model type for writes made with the given context. | | `lighthouse.Broadcast`, `lighthouse.Service.Emit` | One explicit event enqueued on the caller's transaction. | | `lighthouse.BroadcastArgs` | The River job (kind `summer.broadcast`). | | `lighthouse.DefaultTTL` | The default payload ttl, 60 seconds. | | `lighthouse.CallbackSnapshot`, `lighthouse.CallbackAfterCreate`, `lighthouse.CallbackAfterUpdate`, `lighthouse.CallbackAfterDelete` | Names of the GORM callbacks. | | `lighthouse.User`, `lighthouse.UserLookup` | A user id with a display name, and the application's lookup. | | `lighthouse.Actor`, `lighthouse.SystemActor` | Who caused a broadcast: `{"user_id":…,"name":…}`. | | `lighthouse.DurationSetting(cfg, path)` | Reads a duration string or an integer number of seconds. | | `lighthouse.DefaultDriver`, `lighthouse.DefaultQueue`, `lighthouse.DefaultTimeout` | Defaults of `realtime.driver`, `realtime.broadcast_queue` and `realtime.broadcast_timeout`. | ### lighthouse/centrifugo | Identifier | Description | |------------|-------------| | `centrifugo.Config`, `centrifugo.LoadConfig` | The `realtime.centrifugo.*` settings with their defaults, plus `TrustedProxies` from `http.trusted_proxies` for logging client IPs. | | `centrifugo.Client`, `centrifugo.NewClient` | HTTP API client: `Publish`, `Broadcast`, `Presence`, `Unsubscribe`, `Info` (the connectivity probe; an error body is an error), `Enabled`, `DebugInfo`. | | `centrifugo.DebugInfo` | `api_url`, `enabled`, `api_key_set`. | | `centrifugo.TokenIssuer`, `centrifugo.NewTokenIssuer` | HS256 token generators: `ForUser`, `Subscription`, `Anonymous`, `ForIdentifier`, `SubscriptionForIdentifier`, `Configured`. | | `centrifugo.TokenHandler(svc, issuer)` | The token route handler. | | `centrifugo.ProxyHandler(svc, cfg)` | The subscribe proxy handler. | | `centrifugo.Driver`, `centrifugo.NewDriver`, `centrifugo.DriverName` | The `lighthouse.Driver`, with `Client`, `Issuer`, `Config` and `Enabled` (an API key is set). | | `centrifugo.Commands(app)`, `centrifugo.HealthCommandName` | The `websockets:health` command and its name. | | `centrifugo.ErrNotConfigured` | Returned when the API key or token secret an operation needs is empty. | ## Configuration | Key | Default | Description | |-----|---------|-------------| | `realtime.driver` | `null` | `null`, `log`, `memory`, or a registered driver such as `centrifugo`. | | `realtime.broadcast_namespace` | `""` | Prefix applied to broadcast channel names. | | `realtime.broadcast_queue` | `broadcasts` | River queue of broadcast jobs. | | `realtime.broadcast_timeout` | `5` | Broadcast job timeout, in seconds or as a duration string. | | `realtime.centrifugo.api_url` | `http://127.0.0.1:8001/api` | Centrifugo HTTP API base. | | `realtime.centrifugo.api_key` | `""` | HTTP API key; empty disables publishing. | | `realtime.centrifugo.token_secret` | `""` | HS256 token secret; empty makes the token route answer 503. | | `realtime.centrifugo.token_ttl` | `3600` | Token lifetime, in seconds or as a duration string. | | `realtime.centrifugo.ws_url` | `/ws` | WebSocket URL of the Centrifugo server. | | `realtime.centrifugo.proxy_secret` | `""` | Expected `X-Centrifugo-Secret` of subscribe proxy calls. | | `realtime.centrifugo.token_path` | `/api/realtime/token` | Path of the token route. | | `realtime.centrifugo.subscribe_path` | `/api/realtime/subscribe` | Path of the subscribe proxy route. | ## CLI commands `centrifugo.Commands(app)` returns `websockets:health` for the application binary. An application adds it to the list its plugin returns from `Commands`. | Command | Description | |---------|-------------| | `websockets:health` | With an empty `realtime.centrifugo.api_key` it prints `Centrifugo not configured (API key missing)` and exits 1 without sending a request. Otherwise it prints the API URL and calls the Centrifugo `info` API method. On success it prints `Configuration OK` and a Setting/Value table (API URL, Enabled, API Key Set); on any failure it prints `Connection check failed: …` and exits 1. The API key is never printed, only whether it is set. | ## Dependencies - `backpack`, `bouncer`, `compass`, `conga` (the broadcast job), `lagoon` (callback installation), `pact` and `wire` from this repository; the centrifugo driver also uses `surf` for the client IP and `bonfire` for its command. - `gorm.io/gorm` (broadcast callbacks). - `github.com/golang-jwt/jwt/v5` (centrifugo token signing). - The Centrifugo client is plain `net/http`; no Centrifugo SDK is used. ## Testing ```bash go test ./modules/lighthouse/... ``` A test selects `realtime.driver: memory` and reads `lighthouse.MemoryDriver.Publications`, or points `realtime.centrifugo.api_url` at an `httptest` server to see the exact Centrifugo requests. Broadcast tests need a running `conga` worker (`conga.StartWorker`) to deliver the jobs. # pact Source: /docs/api/pact.html Capability interfaces that compiled plugins implement to contribute routes, config, migrations, middleware, commands, admin screens, translations, mail templates, jobs and scheduled commands. `import "git.golem15.com/golem15/summercms/modules/pact"` ## Overview `pact` is the contract layer between plugins and the framework. It holds interfaces and plain data types, with no behaviour of its own. A plugin opts into a capability by implementing one of the `Has*` interfaces, and the framework package that owns the capability discovers it with a type assertion ([party](/docs/api/party.md) for config, [surf](/docs/api/surf.md) for routes and middleware, [lagoon](/docs/api/lagoon.md) for migrations, [cabana](/docs/api/cabana.md) for admin controllers, [conga](/docs/api/conga.md) for jobs and schedules). It replaces the `register*()` methods of a WinterCMS PluginBase (`registerPermissions`, `registerNavigation`, `registerSettings` and so on) with small, separately implementable interfaces. ## Features - Plugin capability interfaces: `pact.HasRoutes`, `pact.HasConfig`, `pact.HasMigrations`, `pact.HasCommands`, `pact.HasModels`, `pact.HasJobs`, `pact.HasSchedule`, `pact.HasLang`, `pact.HasLangOverrides` and `pact.HasMailTemplates`. - HTTP contracts: the `pact.Router` group builder (implemented by surf), the `pact.Middleware` type, and named, parameterized (`name:param`) and house-envelope middleware through `pact.HasMiddleware`, `pact.HasMiddlewareFactories` and `pact.HasHouseMiddleware`. - Backend registration data: `pact.Permission`, `pact.NavigationItem` and `pact.SettingsItem`, exposed through `pact.HasPermissions`, `pact.HasNavigation` and `pact.HasSettings`. - Admin controller contracts: `pact.AdminController`, `pact.HasAdminControllers`, `pact.AdminAssets` (embedded Winter-shaped admin YAML), `pact.AdminPermissioned` and `pact.AdminRecordSource`. - Admin extension contracts, so a plugin extends the compiled admin SPA without a Node build: `pact.AdminClientAssets` (per-controller JS and CSS from the plugin's embedded `assets/` tree, Winter's `addJs`/`addCss`), `pact.HasAdminActions` with `pact.AdminAction`, `pact.AdminActionInput` and `pact.AdminActionResult` (named toolbar and widget actions whose routes, CSRF check, permissions and record scoping the framework owns), and `pact.AdminPartialData` (the curated view model a partial template renders). - Optional admin hooks a controller or model can implement: list and form query scoping (`pact.ListExtendQuery`, `pact.FormExtendQuery`), create, update and delete hooks (`pact.FormBeforeCreate`, `pact.FormAfterUpdate`, `pact.FormBeforeDelete` and their siblings), relation hooks (`pact.RelationExtendManageQuery`, `pact.RelationExtendOptionsQuery`, `pact.RelationBeforeLink`), filter scopes (`pact.FilterScope`, `pact.FilterOptions`) and dropdown options (`pact.DropdownOptionsProvider`). - A background job contract (`pact.Job`, `pact.JobArgs`) that does not depend on any queue library. - A schedule contract: `pact.HasSchedule` returns `pact.ScheduledCommand` entries (a registered command name, its arguments and a `pact.Cadence` built with `pact.Daily`, `pact.DailyAt` or `pact.Every`), the Go form of WinterCMS `registerSchedule`. It does not depend on any queue library either. - `pact.OptionalMessage`, a service an optional plugin can publish so others integrate with it without importing its package. - `pact.HasModels` and `pact.OptionalMessage` are declared for plugins to implement, but no framework package consumes them yet. ## Usage A plugin declares its capabilities by implementing the interfaces and asserting them at compile time: ```go package blog import ( "net/http" "git.golem15.com/golem15/summercms/modules/pact" ) var ( _ pact.HasRoutes = (*Plugin)(nil) _ pact.HasPermissions = (*Plugin)(nil) ) type Plugin struct{} func (p *Plugin) Routes(r pact.Router) error { r.Group("/api/blog", []string{"auth"}, func(r pact.Router) { r.Get("/posts", listPosts) r.Get("/posts/{id}", showPost) r.Where("id", "[0-9]+") }) return nil } func (p *Plugin) Permissions() []pact.Permission { return []pact.Permission{ {Code: "acme.blog.access_posts", Tab: "Blog", Label: "Manage posts"}, } } func listPosts(w http.ResponseWriter, r *http.Request) {} func showPost(w http.ResponseWriter, r *http.Request) {} ``` A plugin schedules one of its registered commands by implementing `pact.HasSchedule`: ```go var _ pact.HasSchedule = (*Plugin)(nil) func (p *Plugin) Schedule() []pact.ScheduledCommand { return []pact.ScheduledCommand{ {Command: "blog:prune-drafts", Cadence: pact.Daily()}, {Command: "blog:ping", Args: []string{"--quiet"}, Cadence: pact.Every(15 * time.Minute)}, } } ``` ## API reference | Identifier | Description | |------------|-------------| | `pact.Router` | Laravel-style route group builder (`pact.Router.Group`, `pact.Router.GroupRaw`, one method per HTTP verb, `pact.Router.Where`, `pact.Router.WhereIn`); implemented by surf. | | `pact.HasRoutes` | Declares HTTP routes on a `pact.Router`. | | `pact.Middleware` | A named `func(http.Handler) http.Handler` wrapper. | | `pact.HasMiddleware` | Registers named middleware. | | `pact.HasMiddlewareFactories` | Registers parameterized middleware resolved from `name:param` at wrap time. | | `pact.HasHouseMiddleware` | Registers middleware tagged as envelope and error handling, which raw groups refuse. | | `pact.HasConfig` | Ships default YAML config, merged under the plugin ID. | | `pact.HasMigrations` | Ships an ordered gormigrate set, run with a per-plugin history table. | | `pact.HasCommands` | Contributes bonfire console commands to the application binary. | | `pact.HasModels` | Exposes GORM models. | | `pact.Job` | Background unit of work that receives `pact.JobArgs`. | | `pact.HasJobs` | Registers background jobs. | | `pact.HasSchedule` | Declares recurring console commands (`Schedule() []pact.ScheduledCommand`); conga workers run them. | | `pact.ScheduledCommand` | One schedule entry: `Command`, `Args` and `Cadence`. | | `pact.Cadence` | Opaque run frequency with `IsZero`, `Interval` (24h for daily cadences) and `At` (hour and minute of a daily cadence). | | `pact.Daily` | Cadence at 00:00 every day in the app timezone (Laravel `->daily()`). | | `pact.DailyAt` | Cadence at a given hour and minute every day in the app timezone. | | `pact.Every` | Cadence at every multiple of an interval since local midnight; the interval must be at least 1s and divide 24h. | | `pact.HasLang` | Ships translation YAML under `lang//.yaml`. | | `pact.HasLangOverrides` | Replaces or adds translations of any loaded namespace, including the framework's own. | | `pact.HasMailTemplates` | Ships mail templates and layout aliases. | | `pact.Permission` | One backend permission entry (code, tab, label, roles). | | `pact.NavigationItem` | One backend navigation entry, with an optional side menu. | | `pact.SettingsItem` | One settings screen entry. | | `pact.AdminController` | Admin controller identity: ID, model name and YAML config directory. | | `pact.AdminAssets` | Embedded tree of the plugin's admin YAML. | | `pact.AdminRecordSource` | Supplies a new model record for the generic admin handlers. | | `pact.AdminClientAssets` | Declares a controller's admin JS (`AdminJS`) and CSS (`AdminCSS`) files, paths under the plugin's `assets/` directory. | | `pact.AdminAction` | One named controller action: name, label, extra permissions and the Go `Run` function. | | `pact.AdminActionInput` | What an action receives: widget field, optional record id and scoped record, and the fill snapshot. | | `pact.AdminActionResult` | What an action returns: a message for the toast and the fill write-back values. | | `pact.HasAdminActions` | Registers a controller's actions for `toolbar.buttons` and `type: widget` fields. | | `pact.AdminPartialData` | Supplies the view model a controller partial template renders; never the GORM model. | | `pact.FilterScope` | Model scopes a list filter may call, limited to an exact allow list. | | `pact.Option` | One dropdown choice (value and label). | ## Dependencies - SummerCMS modules: [bonfire](/docs/api/bonfire.md) (the command type in `pact.HasCommands`). - Third-party: `github.com/go-gormigrate/gormigrate/v2`, `gorm.io/gorm`. - Standard library: `context`, `io/fs`, `net/http`, `time`. ## Testing ```sh go test ./modules/pact/... ``` The tests are compile-time interface checks and need no external services. # party Source: /docs/api/party.html Compiled plugin registry that orders plugins by their dependencies and runs their Register and Boot lifecycle. `import "git.golem15.com/golem15/summercms/modules/party"` ## Overview `party` is the WinterCMS PluginBase and PluginManager counterpart for plugins compiled into the binary. Each plugin package calls `party.Register` from its `init` function, and the application's generated `main` calls `party.Activate` with the plugin IDs listed in its manifest. Activation validates the selection, sorts it so every plugin comes after the plugins it requires, merges plugin config, runs every Register before any Boot, and wires translations and mail templates in between. Capabilities beyond the lifecycle are declared through the interfaces in [pact](/docs/api/pact.md). ## Features - `party.Plugin`, the descriptor every plugin implements: `party.Plugin.ID`, `party.Plugin.Requires`, `party.Plugin.Register` and `party.Plugin.Boot`. - A process-wide, concurrency-safe registry filled by `party.Register` (nil plugins are ignored). - `party.Activate` selects plugins by manifest ID and fails on an empty ID, a duplicate ID, an unregistered plugin, a missing requirement or a dependency cycle. - Stable topological ordering: plugins without a dependency relation keep their manifest order. - Activation sequence: records the ordered IDs on the `backpack.App`, merges each `pact.HasConfig` tree into the app config under the plugin ID, runs every Register, publishes the translator ([phrasebook](/docs/api/phrasebook.md)) and mailer ([postcard](/docs/api/postcard.md)), then registers each plugin's mail templates and runs its Boot. ## Usage A plugin registers itself when its package is imported: ```go package blog import ( "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/party" ) type Plugin struct{} func (p *Plugin) ID() string { return "acme.blog" } func (p *Plugin) Requires() []string { return []string{"acme.user"} } func (p *Plugin) Register(app *backpack.App) error { return nil } func (p *Plugin) Boot(app *backpack.App) error { return nil } func init() { party.Register(&Plugin{}) } ``` The application activates the plugins it lists, in dependency order: ```go cfg, err := compass.Load("config") if err != nil { return err } app := backpack.New(cfg) plugins, err := party.Activate(app, []string{"acme.user", "acme.blog"}) if err != nil { return err } ``` ## API reference | Identifier | Description | |------------|-------------| | `party.Plugin` | Interface every compiled plugin implements: ID, required plugin IDs, Register and Boot. | | `party.Register` | Adds a plugin to the process-wide registry; called from the plugin's `init`. | | `party.Activate` | Selects registered plugins by ID, orders them by `party.Plugin.Requires` and runs the config, Register, translation, mail and Boot steps; returns the ordered plugins. | ## Dependencies - SummerCMS modules: [backpack](/docs/api/backpack.md), [pact](/docs/api/pact.md), [phrasebook](/docs/api/phrasebook.md), [postcard](/docs/api/postcard.md). - Third-party: none. - Standard library: `fmt`, `strings`, `sync`. ## Testing ```sh go test ./modules/party/... ``` The tests use in-memory plugins and `testing/fstest` file systems and need no external services. # phrasebook Source: /docs/api/phrasebook.html Namespaced translation catalogs loaded from plugin YAML, with locale fallback, placeholder interpolation and CLDR pluralization. `import "git.golem15.com/golem15/summercms/modules/phrasebook"` ## Overview phrasebook owns every translatable string in a SummerCMS application. At boot, `phrasebook.Activate` loads the framework's own strings plus the `lang/` tree of every plugin that implements `pact.HasLang`, applies overrides from plugins that implement `pact.HasLangOverrides`, and publishes a single `phrasebook.Translator` on the `backpack.App`. Keys use the WinterCMS form `namespace::group.dot.path`, and message syntax follows Laravel (`:name` placeholders, `|` plural pipes), so it is the counterpart of WinterCMS's `Lang::get` / `trans_choice` and plugin `lang/` directories. ## Features - YAML catalogs laid out as `lang//.yaml`; nested maps flatten into keys such as `acme.blog::posts.title` (`phrasebook.Catalog.Load`). Duplicate keys, duplicate namespaces, malformed paths and non-string leaves fail at load time. - Overrides laid out as `lang///.yaml` that replace keys of any loaded namespace, including the framework admin strings, and may add locales (`phrasebook.Catalog.Override`). - Built-in framework namespaces: `lagoon::validate.*` (validation messages) and `backend::lang.*` (admin UI strings), shipped for `en` and `pl`. - Lookups by request locale (`phrasebook.Translator.Get`, read from the context through `towel.Locale`) or by explicit locale (`phrasebook.Translator.GetIn`), walking the locale, its parent tags (`pt-BR` to `pt`) and then the fallback locale. A missing key returns the key itself. - Laravel-style placeholders: `:name`, `:Name` (first letter upper-cased) and `:NAME` (upper-cased). - Pluralization with `phrasebook.Translator.Choice` and `phrasebook.Translator.ChoiceIn`: CLDR plural maps (`one`, `few`, `many`, `other`, ...) validated against the locale's categories, or Laravel pipes with exact (`{0}`) and range (`[2,*]`) conditions. `:count` is filled in automatically. - Export for the admin SPA: `phrasebook.Translator.Forms` returns a key as CLDR plural forms, `phrasebook.Translator.Bundle` returns every key under a prefix, and `phrasebook.Translator.Resolved` reports which locale such a bundle mostly comes from. Activation fails if a `backend::` string cannot be expressed as CLDR forms. - Missing keys are logged once per key through `log/slog`, except in the production environment. ## Usage Plugins normally only ship a `lang/` tree and implement `pact.HasLang`; the runtime calls `phrasebook.Activate` and handlers look the translator up from the app. A catalog can also be built directly: ```yaml # lang/en/posts.yaml title: Posts greeting: "Hello, :name" count: one: ":count post" other: ":count posts" ``` ```go package blog import ( "context" "embed" "fmt" "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/phrasebook" "git.golem15.com/golem15/summercms/modules/towel" ) //go:embed lang var langFS embed.FS func Example() error { cat := phrasebook.NewCatalog() if err := cat.Load("acme.blog", langFS); err != nil { return err } tr := phrasebook.NewTranslator(cat, phrasebook.Options{Locale: "en", Fallback: "en"}) ctx := towel.WithLocale(context.Background(), "en") fmt.Println(tr.Get(ctx, "acme.blog::posts.greeting", map[string]string{"name": "Ada"})) // Hello, Ada fmt.Println(tr.ChoiceIn("en", "acme.blog::posts.count", 3, nil)) // 3 posts return nil } // Inside a running application, use the translator published at boot. func Title(ctx context.Context, app *backpack.App) string { tr, ok := app.Lookup[*phrasebook.Translator]() if !ok { return "acme.blog::posts.title" } return tr.Get(ctx, "acme.blog::posts.title", nil) } ``` ## API reference | Identifier | Description | |------------|-------------| | `phrasebook.Activate` | Loads framework and plugin catalogs in plugin order, applies overrides and publishes one `phrasebook.Translator` on the app. | | `phrasebook.Catalog` | Set of namespaced translation entries, immutable once loading is done. | | `phrasebook.NewCatalog` | Returns an empty catalog. | | `phrasebook.Catalog.Load` | Loads a plugin's `lang//.yaml` files under the plugin ID namespace. | | `phrasebook.Catalog.Override` | Applies an override tree over already loaded namespaces. | | `phrasebook.Options` | Translator settings: app locale, fallback locale and production mode. | | `phrasebook.Translator` | Resolves namespaced keys against a catalog. | | `phrasebook.NewTranslator` | Builds a translator; empty locale and fallback default to `en`. | | `phrasebook.Translator.Get` | Translates a key in the request locale, or the app locale when the context has none. | | `phrasebook.Translator.GetIn` | Translates a key in an explicit locale. | | `phrasebook.Translator.Choice` | Selects a plural form in the request locale. | | `phrasebook.Translator.ChoiceIn` | Selects a plural form in an explicit locale. | | `phrasebook.Translator.Has` | Reports whether any locale defines a key. | | `phrasebook.Translator.Locale` | Returns the configured app locale. | | `phrasebook.Translator.Forms` | Returns a key as CLDR plural forms for the admin SPA. | | `phrasebook.Translator.Bundle` | Returns all keys under a prefix as CLDR forms, merged over the fallback chain. | | `phrasebook.Translator.Resolved` | Returns the first locale in the fallback chain that has keys under a prefix. | ## Configuration `phrasebook.Activate` reads these keys from the app's [compass](/docs/api/compass.md) config: | Key | Default | Controls | |-----|---------|----------| | `app.locale` | `en` | The app locale, used when a request carries no locale. | | `app.fallback_locale` | `en` | The last locale tried before a key is reported missing. | When the compass environment is `production`, missing-key warnings are not logged. ## Dependencies - SummerCMS modules: [backpack](/docs/api/backpack.md), [pact](/docs/api/pact.md), [towel](/docs/api/towel.md). - Third-party: `github.com/goccy/go-yaml`, `github.com/nicksnyder/go-i18n/v2` (CLDR plural rules), `golang.org/x/text/language`. - Standard library: `context`, `embed`, `fmt`, `io/fs`, `log/slog`, `path`, `regexp`, `sort`, `strconv`, `strings`, `sync`, `unicode`, `unicode/utf8`. ## Testing ```sh go test ./modules/phrasebook/... ``` The tests use in-memory filesystems and need no external services. # postcard Source: /docs/api/postcard.html Transactional mail from plugin-owned Markdown templates and layouts, delivered through a memory, log or SMTP driver. `import "git.golem15.com/golem15/summercms/modules/postcard"` ## Overview postcard is the mail layer of SummerCMS. Plugins that implement `pact.HasMailTemplates` ship WinterCMS-shaped mail files under `views/mail/`; at boot, `postcard.Activate` publishes one app-scoped `postcard.Mailer` built from the compass `mail.*` config, and `postcard.BootPlugin` registers each plugin's templates and layouts as the plugin boots. Callers then send a template by its dotted name with a map of variables. It is the counterpart of WinterCMS's `Mail::send` with `views/mail/*.htm` templates and mail layouts. ## Features - Templates named `::mail.` and loaded from `views/mail/.htm` (dots become directories). A plugin may only register names in its own namespace; missing files, duplicates and unknown layout aliases fail at boot. - WinterCMS file format: an INI header (`subject`, `layout`, `description`), a `==` separator, then a Markdown body rendered with Go `html/template` variables such as `{{ .name }}`. - Layouts with a header, a text wrapper and an HTML wrapper, referenced from templates by a short alias (`pact.HasMailTemplates` maps aliases to full names). A neutral default layout is built in. - Every message gets an HTML part (Markdown converted with goldmark) and a plain-text part. `mail.css` and `mail.brandCss` are inlined into the layout's style block. - Safety checks: rendered HTML with script, iframe, object or embed tags, inline event handlers or `javascript:`/`vbscript:`/`data:` URLs is rejected; CR/LF in the subject or address headers is rejected; addresses are parsed with `net/mail`. - Drivers behind the `postcard.Driver` interface: `postcard.MemoryDriver` (keeps messages for tests), `postcard.LogDriver` (logs headers and the text part through `log/slog`, never the HTML or credentials), `postcard.SMTPDriver` (go-mail with an explicit TLS policy) and `postcard.FailDriver` (a deterministic failure for tests). - No locale selection: callers pass the full template name, including any locale suffix. ## Usage A plugin ships `views/mail/welcome.htm`: ```text subject = "Welcome, {{ .name }}" description = "Sent after registration" == Hi **{{ .name }}**, thanks for joining the blog. ``` and sends it through the mailer the runtime published: ```go package blog import ( "context" "errors" "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/postcard" ) func SendWelcome(ctx context.Context, app *backpack.App, email, name string) error { mailer, ok := app.Lookup[postcard.Mailer]() if !ok { return errors.New("mailer not published") } return mailer.Send(ctx, postcard.Message{ Template: "acme.blog::mail.welcome", To: []string{email}, Vars: map[string]any{"name": name}, }) } ``` In tests, build the catalog and mailer directly and inspect what was sent: ```go package blog import ( "context" "testing" "testing/fstest" "git.golem15.com/golem15/summercms/modules/postcard" ) func TestWelcomeMail(t *testing.T) { mailFS := fstest.MapFS{"views/mail/welcome.htm": {Data: []byte("subject = \"Welcome, {{ .name }}\"\n==\nHi **{{ .name }}**.\n")}} cat := postcard.NewCatalog() if err := cat.Register("acme.blog", mailFS, []string{"acme.blog::mail.welcome"}, nil); err != nil { t.Fatal(err) } driver := postcard.NewMemoryDriver() mailer := postcard.NewMailer(cat, driver, postcard.Options{From: "blog@example.com"}) msg := postcard.Message{Template: "acme.blog::mail.welcome", To: []string{"ada@example.com"}, Vars: map[string]any{"name": "Ada"}} if err := mailer.Send(context.Background(), msg); err != nil { t.Fatal(err) } if got := driver.Messages()[0].Subject; got != "Welcome, Ada" { t.Fatalf("subject = %q", got) } } ``` ## API reference | Identifier | Description | |------------|-------------| | `postcard.Activate` | Builds the driver from config and publishes the app-scoped `postcard.Mailer` before plugins boot. | | `postcard.BootPlugin` | Registers a booting plugin's declared templates and layouts with the published mailer. | | `postcard.Mailer` | Sends a registered template through the configured driver. | | `postcard.NewMailer` | Builds a mailer from a catalog, a driver and `postcard.Options`. | | `postcard.Message` | What callers send: template name, recipients, reply-to, variables and an optional subject override. | | `postcard.Options` | Mailer settings: sender address, CSS and brand CSS. | | `postcard.Catalog` | Registered templates and layouts, including the built-in default layout. | | `postcard.NewCatalog` | Returns a catalog holding only the default layout. | | `postcard.Catalog.Register` | Loads a plugin's templates and layout aliases from an `fs.FS`. | | `postcard.Driver` | Delivers a `postcard.RenderedMessage`. | | `postcard.RenderedMessage` | The validated payload a driver transmits: headers, HTML part and text part. | | `postcard.NewMemoryDriver` | In-memory driver; `postcard.MemoryDriver.Messages` returns what was sent. | | `postcard.NewLogDriver` | Driver that logs through a `*slog.Logger` (the default logger when nil). | | `postcard.NewSMTPDriver` | SMTP driver built from a `postcard.SMTPConfig`. | | `postcard.SMTPConfig` | Host, port, credentials, TLS policy and timeout for the SMTP driver. | | `postcard.FailDriver` | Driver whose `postcard.FailDriver.Send` always returns `postcard.FailDriver.Err`. | ## Configuration `postcard.Activate` reads these keys from the app's [compass](/docs/api/compass.md) config: | Key | Default | Controls | |-----|---------|----------| | `mail.driver` | `memory` | Delivery driver: `memory`, `log` or `smtp`. Any other value fails at boot. | | `mail.from` | empty | Sender address. Required by the SMTP driver. | | `mail.css` | empty | CSS inlined into the layout. | | `mail.brandCss` | empty | Brand CSS inlined before `mail.css`. | | `mail.smtp.host` | none | SMTP host. Required when `mail.driver` is `smtp`. | | `mail.smtp.port` | `587` | SMTP port. | | `mail.smtp.username` | empty | SMTP user. When set, PLAIN authentication is used. | | `mail.smtp.password` | empty | SMTP password. | | `mail.smtp.tls` | `mandatory` | TLS policy: `mandatory` (or `tls`), `starttls` (or `opportunistic`), `none` (or `notls`, `off`). Plain connections are never inferred. | | `mail.smtp.timeout` | `10s` | Connection timeout, as a Go duration or a number of seconds. | ```yaml mail: driver: smtp from: blog@example.com smtp: host: smtp.example.com port: 587 username: blog password: tls: mandatory ``` ## Dependencies - SummerCMS modules: [backpack](/docs/api/backpack.md), [compass](/docs/api/compass.md), [pact](/docs/api/pact.md). - Third-party: `github.com/wneessen/go-mail` (SMTP), `github.com/yuin/goldmark` (Markdown). - Standard library: `bytes`, `context`, `embed`, `fmt`, `html/template`, `io/fs`, `log/slog`, `net/mail`, `regexp`, `strings`, `sync`, `text/template`, `time`. - Tests additionally use `github.com/testcontainers/testcontainers-go` (Mailpit container). ## Testing ```sh go test ./modules/postcard/... ``` The SMTP integration test starts a Mailpit container through testcontainers-go and needs Docker. Run `go test -short ./modules/postcard/...` to skip it; the remaining tests use the memory and fail drivers and need no external services. # surf Source: /docs/api/surf.html HTTP routing for SummerCMS: collects plugin routes and named middleware into a `net/http` ServeMux with constraints, rate limiting, body limits, CORS and panic recovery, and provides the `serve` and `route:list` commands. `import "git.golem15.com/golem15/summercms/modules/surf"` ## Overview surf turns the routes that plugins declare through `pact.HasRoutes` into one `http.Handler`. `surf.BuildRouter` registers the built-in and plugin middleware, walks every plugin's route declarations through a Laravel-style group builder (`surf.Router`, implementing `pact.Router`), mounts the [cabana](/docs/api/cabana.md) admin, and checks every route at boot; `surf.Assemble` then compiles the result onto a standard library ServeMux. Configuration mistakes such as duplicate routes, unknown middleware names or malformed throttles fail at boot, not on the first request. It is the counterpart of WinterCMS's plugin `routes.php` files with Laravel's `Route::group`, `->middleware()`, `->where()` and `throttle` middleware. ## Features - Laravel-style route groups: `surf.Router.Group` with a path prefix and middleware list (`surf.Use` builds the list), `surf.Router.Get`, `surf.Router.Post`, `surf.Router.Put`, `surf.Router.Patch` and `surf.Router.Delete` (the same methods exist on each `surf.Group`), with Go 1.22+ path patterns such as `/posts/{id}`. - Path constraints: `surf.Router.Where` (regex, anchored to the whole segment) and `surf.Router.WhereIn` (allow-list) apply to the last declared route; a request that fails a constraint gets a 404. `surf.IntParam` reads a positive integer path value. - Named middleware from plugins (`pact.HasMiddleware`), parameterized middleware used as `name:param` (`pact.HasMiddlewareFactories`) and house middleware for the JSON envelope and error handling (`pact.HasHouseMiddleware`). Duplicate or unknown names fail boot. - Raw groups (`surf.Router.GroupRaw`) for routes that must not be wrapped in house middleware, such as webhooks or file streams: house middleware is refused there, the default body limit is skipped and a panic returns a bare 500. - Built-in middleware names: `throttle:` or `throttle:,`, `body.limit:`, `locale.from-principal`, plus `backend` (the admin guard) when the admin is enabled. - Fixed-window rate limiting (`surf.FixedWindowLimiter`): named buckets from plugins that implement `surf.BucketProvider`, or inline limits keyed by the signed-in user, or by client IP for guests. Rejected requests get a 429 with `Retry-After` and `X-RateLimit-*` headers. The in-process `surf.MemoryStore` sits behind the `surf.Store` interface. - Client IP resolution for limiter keys (`surf.ClientIP`) that only trusts `X-Forwarded-For` hops when the direct peer is inside a configured trusted proxy range (`surf.TrustedProxies`). - Every non-raw route runs inside JSON panic recovery (an opaque 500 via [wire](/docs/api/wire.md)), gets the request locale from the `Accept-Language` header (see [towel](/docs/api/towel.md)) and a request body cap. Responses are buffered until the handler returns, so a panic never leaves a half-written body. - Path-scoped CORS configured with the same keys as Laravel's `config/cors.php` (`surf.CORSConfig`), including preflight handling. - `surf.LocaleFromPrincipal` switches the request locale to the signed-in user's preferred locale. - A read-only route table (`surf.Router.Routes`) and the `serve` and `route:list` commands. ## Usage A plugin declares routes, middleware and a rate-limit bucket; the runtime assembles them: ```go package blog import ( "net/http" "time" "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/pact" "git.golem15.com/golem15/summercms/modules/surf" "git.golem15.com/golem15/summercms/modules/wire" ) type Plugin struct{} func (Plugin) ID() string { return "acme.blog" } func (Plugin) Requires() []string { return nil } func (Plugin) Register(*backpack.App) error { return nil } func (Plugin) Boot(*backpack.App) error { return nil } func (Plugin) Middlewares() map[string]pact.Middleware { return map[string]pact.Middleware{ "blog.no-store": func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "no-store") next.ServeHTTP(w, r) }) }, } } func (Plugin) Buckets() map[string]surf.Bucket { return map[string]surf.Bucket{ "blog.comments": { Max: 5, Decay: time.Minute, Key: func(r *http.Request) string { return "comments|" + surf.ClientIP(r, nil) }, }, } } func (Plugin) Routes(r pact.Router) error { r.Group("/api/blog", surf.Use("throttle:60,1"), func(g pact.Router) { g.Get("/posts/{id}", showPost) g.Where("id", `[0-9]+`) g.Get("/posts/{status}/list", listPosts, "blog.no-store") g.WhereIn("status", "draft", "published") g.Post("/posts/{id}/comments", addComment, "throttle:blog.comments", "body.limit:65536") }) return nil } func showPost(w http.ResponseWriter, r *http.Request) { id, ok := surf.IntParam(r, "id") if !ok { http.NotFound(w, r) return } wire.WriteJSON(w, http.StatusOK, map[string]any{"id": id}) } func listPosts(w http.ResponseWriter, r *http.Request) { wire.WriteJSON(w, http.StatusOK, []string{}) } func addComment(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusCreated) } ``` The generated application `main` wires surf in with `surf.ServeCommand` and `surf.RouteListCommand`; tests can call `surf.Assemble(app, plugins)` and drive the returned handler with `net/http/httptest`. ## API reference | Identifier | Description | |------------|-------------| | `surf.BuildRouter` | Registers built-in and plugin middleware, buckets, routes and the admin, and validates every route without compiling. | | `surf.Assemble` | `surf.BuildRouter` plus compilation into the final `http.Handler`. | | `surf.Router` | The route builder; implements `pact.Router`. `surf.New` creates an empty one. | | `surf.Group` | A prefixed route collection with inherited middleware. | | `surf.Router.RegisterMiddleware` | Stores a named middleware; duplicates fail. | | `surf.Router.RegisterMiddlewareFactory` | Stores a parameterized middleware used as `name:param`. | | `surf.Router.Routes` | Returns a copy of the registered routes as `surf.RouteInfo` values. | | `surf.RouteInfo` | Method, pattern, owning plugin, middleware and raw flag of one route. | | `surf.Use` | Builds a middleware name list for a group. | | `surf.Constraint` | A compiled path-parameter restriction built by `surf.Regex` or `surf.Enum`. | | `surf.IntParam` | Reads a positive integer path value. | | `surf.Bucket` | A named rate limit: maximum attempts, window length and key function. | | `surf.BucketProvider` | Implemented by plugins that declare named buckets. | | `surf.FixedWindowLimiter` | The rate limiter behind the `throttle` middleware; `surf.NewFixedWindowLimiter` creates one. | | `surf.Store` | Atomic fixed-window admission; `surf.NewMemoryStore` is the in-process implementation. | | `surf.ClientIP` | Resolves the client IP, honouring trusted proxies. | | `surf.TrustedProxies` | Parses `http.trusted_proxies` into CIDR prefixes. | | `surf.CORSConfig` | CORS settings; `surf.LoadCORSConfig` reads them from config. | | `surf.LocaleFromPrincipal` | Middleware that applies the signed-in user's preferred locale. | | `surf.ServeCommand` | The `serve` console command. | | `surf.RouteListCommand` | The `route:list` console command. | ## Configuration `surf.BuildRouter` reads these keys from the app's [compass](/docs/api/compass.md) config: | Key | Default | Controls | |-----|---------|----------| | `http.body_limits.default_bytes` | none, required | Request body cap in bytes for every non-raw route; `body.limit:` overrides it per route. Must be a whole number of at least 1. | | `http.body_limits.upload_bytes` | none, required | Upload body cap in bytes. Must be a whole number of at least 1; it is validated at boot. | | `http.trusted_proxies` | empty | List of CIDRs whose `X-Forwarded-For` header is trusted when resolving the client IP. Malformed entries are skipped. | | `http.cors.paths` | empty | Path globs (`api/*`) that get CORS headers. With no `http.cors` section no CORS headers are sent. | | `http.cors.allowed_origins` | empty | Allowed origins; `*` allows any. | | `http.cors.allowed_origins_patterns` | empty | Regular expressions matched against the origin. | | `http.cors.allowed_methods` | empty | Methods sent in `Access-Control-Allow-Methods`; `*` allows any. | | `http.cors.allowed_headers` | empty | Headers sent in `Access-Control-Allow-Headers`; `*` allows any. | | `http.cors.exposed_headers` | empty | Headers sent in `Access-Control-Expose-Headers`. | | `http.cors.max_age` | `0` | Preflight cache time in seconds. | | `http.cors.supports_credentials` | `false` | Sends `Access-Control-Allow-Credentials: true`. | ```yaml http: body_limits: default_bytes: 1048576 upload_bytes: 20971520 trusted_proxies: ["10.0.0.0/8"] cors: paths: ["api/*"] allowed_origins: ["https://blog.example.com"] allowed_methods: ["*"] allowed_headers: ["*"] supports_credentials: true ``` The `serve` command also opens the database through [lagoon](/docs/api/lagoon.md) and the uploads bucket through `attach.OpenBucket`, so their settings must be present as well. It starts the background job worker of [conga](/docs/api/conga.md) in the same process unless `queue.work_in_serve` is `false` (default `true`); set it to `false` when a separate `queue:work` process runs the jobs. The other `queue.*` keys are documented in the conga README. ## CLI commands | Command | Flags | Description | |---------|-------|-------------| | `serve` | `--addr` (default `:8080`) | Opens the database and uploads bucket, assembles the router, starts the in-process job worker (see `queue.work_in_serve`) and serves HTTP until SIGINT or SIGTERM, then shuts the server and the worker down gracefully within 10 seconds. | | `route:list` | none | Builds the router the same way `serve` does, without opening the database or listening, and prints a table of method, pattern, plugin, middleware and raw flag for every route. | ## Dependencies - SummerCMS modules: [backpack](/docs/api/backpack.md), [bonfire](/docs/api/bonfire.md), [bouncer](/docs/api/bouncer.md), [cabana](/docs/api/cabana.md), [compass](/docs/api/compass.md), [conga](/docs/api/conga.md) (the in-process job worker of `serve`), [lagoon](/docs/api/lagoon.md) (including `lagoon/attach`), [pact](/docs/api/pact.md), [party](/docs/api/party.md), [towel](/docs/api/towel.md), [wire](/docs/api/wire.md). - Third-party: `gocloud.dev/blob` (uploads bucket opened by `serve`). - Standard library: `bytes`, `context`, `fmt`, `math`, `net`, `net/http`, `net/netip`, `os`, `os/signal`, `regexp`, `strconv`, `strings`, `sync`, `syscall`, `time`. ## Testing ```sh go test ./modules/surf/... ``` The tests use `net/http/httptest` and in-memory stores and need no external services. # tide Source: /docs/api/tide.html HTTP parity toolkit that records request and response fixtures from a reference backend, replays them against a new one and reports normalized differences. `import "git.golem15.com/golem15/summercms/modules/tide"` ## Overview `tide` is the acceptance-test engine for porting an existing WinterCMS or other PHP backend to SummerCMS: the reference backend's real responses define the contract, and the Go port must reproduce them. It records YAML fixtures (flows of request and response steps) either by driving a spec against a target or by sitting as a loopback reverse proxy in front of the reference backend while a real client uses it, then replays those fixtures against the port and diffs the responses after masking values that legitimately differ, such as IDs and timestamps. It also checks realtime side effects: a fake Centrifugo recorder captures the publications a backend sends while a flow runs, and broadcast golden files hold them normalised for comparison. The `summer` CLI's parity:proxy, parity:record, parity:replay and parity:broadcasts commands are thin wrappers around this package. It has no WinterCMS counterpart. ## Features - Flow fixtures: `tide.Flow` is a versioned, ordered list of `tide.Step` values, loaded strictly (unknown fields rejected) with `tide.LoadFlow` and `tide.ParseFlow`, and written atomically with `tide.SaveFlow` or `tide.SaveFlowExclusive`. Response bodies can live in sidecar files, confined to the fixture directory and checked against an optional SHA-256 digest. - Recording: `tide.RecordFlow` executes a spec flow against a target URL and fills in the responses, with a bounded body size (`tide.DefaultMaxBody`, 8 MiB). - Recording proxy: `tide.NewProxy` builds a reverse proxy that only binds to and forwards to loopback addresses, groups traffic into named sessions (from the `tide.SessionHeader` request header or a default session) and writes one fixture per complete session on `tide.Proxy.Flush`. - Capture rules: `tide.Rules` (loaded with `tide.LoadRules`) decide which request and response headers are kept per route and which values are captured into variables, from response JSON paths, headers, redirect query strings or form fields. - Variables: `tide.Store` holds captured values such as tokens and IDs in a mode-0600 file, `tide.Store.Expand` substitutes `{{name}}` placeholders before a request is sent, and `tide.ScrubStep` puts placeholders back into fixtures. Scrubbing fails when a step still holds an unclassified token- or password-shaped value, so credentials do not leak into committed fixtures. - Replay and diff: `tide.ReplayFlow` re-sends each step, compares status, a fixed set of contract headers and the body, and returns `tide.Result` with per-step `tide.Diff` entries. JSON bodies are compared structurally after masking `id`, `*_id` and `*_ids` values and `*_at` timestamps; other bodies are compared byte for byte. - Fake Centrifugo: `tide.NewCentrifugoRecorder` returns an `http.Handler` that records every POST to a path ending in `/publish` or `/broadcast` as a `tide.Publication` (method, path, whether `Authorization: apikey ` carried the configured key, JSON body) and answers `{"result":{}}`. Paths ending in `/presence` answer `{"result":{"presence":{}}}`, `/unsubscribe` and `/info` answer `{"result":{}}`, anything else is 404. Bodies are capped at `tide.MaxPublicationBody` (1 MiB). The API key is only compared, never stored. `tide.CentrifugoRecorder.ListenAndServe` binds loopback addresses only, like the recording proxy. - Broadcast goldens: `tide.RecordBroadcasts` runs a flow against a loopback reference backend whose Centrifugo API URL points at a recorder on `tide.DefaultCentrifugoListen` (`127.0.0.1:8424`). With `tide.BroadcastConfig` `Step` set, earlier steps run as setup and only that step's publications are kept. The result is a `tide.BroadcastGolden`, written with `tide.WriteBroadcastGolden` (which refuses token-shaped bodies) and read strictly with `tide.LoadBroadcastGolden`. A golden with `pending` set is recorded but not yet asserted. - Broadcast normalisation: `tide.NormalizePublications` masks only `$.data.timestamp` and `$.data.payload.timestamp` (ISO 8601 with an offset) as `"{{timestamp}}"`, `$.data.payload.actor` (an object of exactly `user_id` and `name`) as `"{{actor}}"`, and values equal to an `id:*` variable of a `tide.Store`: numbers or strings under `id`, `*_id` or `*_ids` keys, and the numeric last segment of a channel name such as `room:12`. A masked number is written as a bare `{{id:name}}`, so a number that becomes a string still differs. A value matching two id variables is an error. `tide.DiffPublications` compares the count, method, path, authorization flag and body (structurally, key order ignored) and reports paths such as `$[0].body.data.payload.id`. - Manifests: `tide.Manifest` lists routes with auth groups, a pending or ported status, cases and fixture paths; `tide.RecordManifest` records missing cases in batches of at most `tide.MaxBatch`, and `tide.ReplayManifest` replays every recorded case into a `tide.Coverage` table. ## Usage Record a flow once against the reference backend, then replay it against the port: ```go spec, err := tide.LoadFlow("testdata/parity/posts.spec.yaml") if err != nil { return err } flow, err := tide.RecordFlow(ctx, spec, tide.RecordConfig{Target: "http://127.0.0.1:8000"}) if err != nil { return err } if err := tide.SaveFlow("testdata/parity/posts.yaml", flow); err != nil { return err } res, err := tide.ReplayFlow(ctx, flow, tide.ReplayConfig{ Target: "http://127.0.0.1:8080", BaseDir: "testdata/parity", }) if err != nil { return err } for _, step := range res.Steps { for _, d := range step.Diffs { fmt.Printf("%s %s: want %s, got %s\n", step.ID, d.Path, d.Expected, d.Actual) } } ``` Record the publications a reference backend sends for one flow step, then compare a new backend's publications with them: ```go golden, err := tide.RecordBroadcasts(ctx, spec, tide.BroadcastConfig{ Target: "http://127.0.0.1:8000", APIKey: "test-only-key", Store: store, Step: "delete", IDs: []string{"id:room", "id:item"}, }) if err != nil { return err } if err := tide.WriteBroadcastGolden("testdata/broadcasts/deleted.yaml", golden); err != nil { return err } // Later, with the new backend publishing to rec (a tide.CentrifugoRecorder): norm, err := tide.NormalizePublications(rec.Publications(), idsOfTheNewBackend) if err != nil { return err } for _, d := range tide.DiffPublications(golden.Publications, norm) { fmt.Printf("%s: want %s, got %s\n", d.Path, d.Expected, d.Actual) } ``` ## API reference | Identifier | Description | |------------|-------------| | `tide.Flow` | Versioned, ordered list of request and response steps; the fixture format. | | `tide.Step` | One request and response pair, with capture and normalizer overrides. | | `tide.LoadFlow` | Reads and validates a flow file. | | `tide.SaveFlow` | Writes a validated flow atomically. | | `tide.RecordFlow` | Executes a spec flow against a target and returns the recorded flow. | | `tide.ReplayFlow` | Replays a recorded flow against a target and diffs every step. | | `tide.Result` | Replay outcome: overall status and per-step `tide.StepResult` values. | | `tide.Diff` | One structural JSON or byte-level mismatch. | | `tide.MismatchError` | Error that carries a failing `tide.Result`. | | `tide.NewProxy` | Builds the loopback recording reverse proxy from a `tide.ProxyConfig`. | | `tide.Proxy` | The recording proxy: `tide.Proxy.Handler`, `tide.Proxy.ListenAndServe`, `tide.Proxy.Flush`. | | `tide.Rules` | Header keep lists and capture rules for proxy sessions. | | `tide.Store` | Named capture variables, optionally persisted to a private file. | | `tide.OpenStore` | Opens a file-backed store, or a memory-only store for an empty path. | | `tide.Manifest` | Route list with auth groups, status, cases and fixture paths. | | `tide.ValidateManifest` | Checks a manifest in `tide.ModeAllowIncomplete` or `tide.ModeRequireRecorded` mode. | | `tide.RecordManifest` | Records a manifest's seed flow and missing route cases in batches. | | `tide.ReplayManifest` | Replays every recorded route case and builds a coverage table. | | `tide.CentrifugoRecorder` | Fake Centrifugo HTTP API: `tide.CentrifugoRecorder.Publications`, `tide.CentrifugoRecorder.Reset`, `tide.CentrifugoRecorder.ListenAndServe`. | | `tide.NewCentrifugoRecorder` | Builds a recorder from `tide.CentrifugoRecorderOptions` (the expected API key). | | `tide.Publication` | One recorded publish or broadcast request: method, path, authorization flag, JSON body. | | `tide.RecordBroadcasts` | Runs a flow (or one step of it) against a loopback backend and returns its normalised publications as a golden. | | `tide.BroadcastConfig` | Target, recorder address, API key, vars store, step, id variables and settle time for `tide.RecordBroadcasts`. | | `tide.BroadcastGolden` | Versioned broadcast golden: name, flow, optional pending reason, publications. | | `tide.LoadBroadcastGolden` | Reads a golden strictly and checks every body parses. | | `tide.WriteBroadcastGolden` | Writes a golden atomically, refusing token-shaped bodies. | | `tide.NormalizePublications` | Masks timestamps, the actor and captured ids in publication bodies. | | `tide.DiffPublications` | Structural diff of two publication lists. | | `tide.DefaultCentrifugoListen` | Default recorder address, `127.0.0.1:8424`. | | `tide.Coverage` | Recorded, passing, failing and unrecorded counts, with table rows and a summary line. | ## CLI commands `summer parity:broadcasts` wraps `tide.RecordBroadcasts` and `tide.WriteBroadcastGolden`: ```sh summer parity:broadcasts \ --flow testdata/broadcasts/flows/item-lifecycle.yaml \ --step delete --name deleted --ids id:room,id:item \ --target http://127.0.0.1:8000 \ --vars /tmp/parity/vars.yaml \ --api-key test-only-key \ --out testdata/broadcasts/deleted.yaml ``` `--listen` defaults to `127.0.0.1:8424`, `--api-key` to `$PARITY_CENTRIFUGO_API_KEY` and `--settle` to `500ms`. `--ids` defaults to the `id:*` variables the flow mentions. `--pending` stores a reason the golden is not asserted yet. The target and the listen address must be loopback, and the vars file must be outside the golden's directory. The command writes: ```yaml version: 1 name: deleted flow: "items/lifecycle#delete" publications: - method: POST path: /api/publish authorization: true body: |- {"channel":"room:{{id:room}}","data":{"event":"deleted","payload":{"id":{{id:item}},"actor":"{{actor}}","timestamp":"{{timestamp}}"},"timestamp":"{{timestamp}}"}} ``` ## Dependencies - SummerCMS modules: none. - Third-party: `github.com/goccy/go-yaml` (fixture, rules and manifest parsing). - Standard library: `net/http`, `net/http/httputil`, `encoding/json`, `crypto/sha256`, `crypto/subtle`, among others. ## Testing ```sh go test ./modules/tide/... ``` The tests run recording, the proxy, replay and the fake Centrifugo recorder against local `net/http/httptest` servers and use the sample spec in `modules/tide/testdata/`; they need no external services. # towel Source: /docs/api/towel.html Request-scoped actor, organization, collection and locale values carried through `context.Context`. `import "git.golem15.com/golem15/summercms/modules/towel"` ## Overview `towel` replaces the request-global state that WinterCMS reads through facades (the current locale, the acting user) with explicit values on the request context. Middleware stores a value once, and any code further down the call chain that receives the context reads it back without a global lookup. [surf](/docs/api/surf.md) sets the locale from `Accept-Language` (or the signed-in user's preferred locale) and the organization for every request, [phrasebook](/docs/api/phrasebook.md) reads the locale when it translates, and [cabana](/docs/api/cabana.md) sets it while localizing admin schemas. ## Features - Four independent string values, each with a setter and a getter: actor (`towel.WithActor`, `towel.Actor`), organization (`towel.WithOrganization`, `towel.Organization`), collection (`towel.WithCollection`, `towel.Collection`) and locale (`towel.WithLocale`, `towel.Locale`). - Unexported context key types, so no other package can read or overwrite the values by accident. - Getters return `(value, ok)`: a value that was set to an empty string is distinguishable from one that was never set. - Nil-safe: a setter given a nil context starts from `context.Background()`, and a getter given a nil context reports `false`. ## Usage ```go package blog import ( "fmt" "net/http" "git.golem15.com/golem15/summercms/modules/towel" ) // withAcmeScope tags every request with the organization and collection it serves. func withAcmeScope(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ctx := towel.WithOrganization(r.Context(), "acme") ctx = towel.WithCollection(ctx, "blog") next.ServeHTTP(w, r.WithContext(ctx)) }) } func listPosts(w http.ResponseWriter, r *http.Request) { org, _ := towel.Organization(r.Context()) locale, ok := towel.Locale(r.Context()) if !ok { locale = "en" } fmt.Fprintf(w, "posts for %s in %s\n", org, locale) } ``` ## API reference | Identifier | Description | |------------|-------------| | `towel.WithActor` / `towel.Actor` | Store and read the acting user or client identifier. | | `towel.WithOrganization` / `towel.Organization` | Store and read the organization (tenant) the request belongs to. | | `towel.WithCollection` / `towel.Collection` | Store and read the collection the request operates on. | | `towel.WithLocale` / `towel.Locale` | Store and read the locale used for translations and localized responses. | ## Dependencies - SummerCMS modules: none. - Third-party: none. - Standard library: `context`. ## Testing ```sh go test ./modules/towel/... ``` The tests exercise plain contexts and need no external services. # wire Source: /docs/api/wire.html JSON response helpers and value types that keep API bodies byte-compatible with a PHP (WinterCMS/Laravel) backend. `import "git.golem15.com/golem15/summercms/modules/wire"` ## Overview `wire` is the lowest layer of the HTTP stack: it decides how a Go value becomes response bytes. Its helpers reproduce what `json_encode` and Carbon produce in a WinterCMS or Laravel app (unescaped HTML characters, no trailing newline, `+00:00` timestamps, nullable booleans, `[]` rather than `null` for empty lists), so an endpoint ported from PHP returns the same body its existing clients already parse. [surf](/docs/api/surf.md) uses it for the opaque 500 response of its panic recovery, and application handlers use it directly for their JSON responses. ## Features - `wire.WriteJSON` encodes a value with HTML escaping disabled and without the trailing newline `encoding/json` adds, then sets `Content-Type: application/json` and the status code. If encoding fails, it writes the opaque 500 body instead of a partial response. - `wire.WriteOpaque500` writes a 500 response with the fixed body `{"error":true,"message":"Internal server error"}`, which reveals nothing about the failure. - `wire.Time` wraps `time.Time` and always marshals in UTC as `2006-01-02T15:04:05+00:00` (Carbon's form, never Go's `Z`). It unmarshals a timestamp with a numeric offset or an RFC 3339 `Z` timestamp, and turns `null` into the zero time. - `wire.TriBool` models a nullable boolean: when `wire.TriBool.Valid` is false it marshals as `null`, otherwise as `wire.TriBool.Value`. - `wire.Slice` returns a non-nil empty slice for a nil input, so optional lists marshal as `[]` instead of `null`. ## Usage ```go package blog import ( "net/http" "time" "git.golem15.com/golem15/summercms/modules/wire" ) type postJSON struct { ID uint `json:"id"` Title string `json:"title"` Tags []string `json:"tags"` Featured wire.TriBool `json:"featured"` PublishedAt wire.Time `json:"published_at"` } func showPost(w http.ResponseWriter, r *http.Request) { var tags []string // nil when the post has no tags body := postJSON{ ID: 1, Title: "Hello & welcome", // "&" stays unescaped Tags: wire.Slice(tags), // marshals as [] Featured: wire.TriBool{}, // marshals as null PublishedAt: wire.Time{Time: time.Now()}, // "...+00:00" } wire.WriteJSON(w, http.StatusOK, map[string]any{"data": body}) } ``` ## API reference | Identifier | Description | |------------|-------------| | `wire.WriteJSON` | Writes a JSON body with HTML escaping off and no trailing newline; falls back to the opaque 500 on an encoding error. | | `wire.WriteOpaque500` | Writes the fixed `{"error":true,"message":"Internal server error"}` 500 response. | | `wire.Time` | `time.Time` wrapper that marshals as UTC `+00:00` and reads both `+00:00` and `Z` forms. | | `wire.TriBool` | Nullable boolean: `wire.TriBool.Valid` false marshals `null`, otherwise `wire.TriBool.Value`. | | `wire.Slice` | Generic helper that turns a nil slice into an empty one so it marshals as `[]`. | ## Dependencies - SummerCMS modules: none. - Third-party: none. - Standard library: `bytes`, `encoding/json`, `net/http`, `time`. ## Testing ```sh go test ./modules/wire/... ``` The tests use `net/http/httptest` and need no external services. # wristband Source: /docs/api/wristband.html An OAuth authorization server for MCP clients: RFC 8414 metadata, RFC 7591 dynamic client registration, the authorization-code flow with PKCE, consent operations and refresh-token rotation over application-supplied storage. `import "git.golem15.com/golem15/summercms/modules/wristband"` ## Overview wristband implements the protocol side of an OAuth authorization server so an application can let MCP clients (AI assistants and connectors) act on behalf of its users. A `wristband.Server` provides ready-made `net/http` handlers for the metadata, authorize, token and registration endpoints, plus Go methods the application's own consent screen calls. Everything application-specific stays outside the package: persistence arrives through the `wristband.Backend` and `wristband.Tx` interfaces, access tokens are minted by the application's `wristband.AccessTokenIssuer`, and issuer, scopes and lifetimes come from `wristband.Options`. It imports no ORM and no application package. WinterCMS core has no counterpart. ## Features - RFC 8414 metadata document (`wristband.Server.Metadata`) advertising the authorize, token and registration endpoints under the issuer, the `authorization_code` and `refresh_token` grants, S256 PKCE and the RFC 9207 `iss` response parameter. - RFC 7591 dynamic client registration (`wristband.Server.Register`): JSON only, a bounded request body, redirect URI validation (HTTPS, or loopback HTTP), public (`none`) and confidential (`client_secret_post`, `client_secret_basic`) clients, a cap on unrevoked clients and a sweep of old clients that never got consent, all in one transaction. - Authorization endpoint (`wristband.Server.Authorize`): the client and its exact registered redirect URI are validated before any redirect is sent (an unknown client gets a local plain-text 400, never an open redirect); then S256 PKCE, the client's scope ceiling and the RFC 8707 `resource` value are checked, a pending request is stored and the browser is sent to the application's consent page at `/connect?request=`. Accepted scopes are `read`, `write`, `ai` and `offline_access`. - Consent operations for the application's own consent screen: `wristband.Server.PendingRequest` (what to show), `wristband.Server.IssueCode` (grant, returning the redirect URL with `code`, `iss` and `state`) and `wristband.Server.DenyPending` (returning an `access_denied` redirect). Missing, foreign, used and expired requests all report the same `wristband.ErrPendingNotFound`. - Token endpoint (`wristband.Server.Token`): code exchange with PKCE verification, then an access token minted by the application plus a rotating refresh token. Reusing a spent refresh token revokes its whole lineage and the linked access tokens. Expired codes and refresh tokens are swept on each call. - Connected-app revocation: `wristband.Server.Revoke` kills an access token and the refresh lineage attached to it. - Secret handling: client secrets, codes and refresh tokens are random base64url strings, persisted only as SHA-256 hashes and compared in constant time. `wristband.IssueClientCredentials` and `wristband.RejectRedirectURI` expose the same issuing and validation rules to operator tooling that creates clients outside registration. ## Usage ```go package oauth import ( "context" "encoding/json" "net/http" "git.golem15.com/golem15/summercms/modules/pact" "git.golem15.com/golem15/summercms/modules/wristband" ) func NewServer(backend wristband.Backend) *wristband.Server { opts := wristband.DefaultOptions() opts.Issuer = "https://blog.example.com" // app URL without a trailing slash opts.Resource = "https://blog.example.com/mcp" opts.ScopesSupported = []string{"read", "write", "offline_access"} srv := wristband.NewServer(opts) srv.SetBackend(backend) // the application's transactional store adapter return srv } // Routes mounts the RFC endpoints in a raw group: no JSON envelope middleware. func Routes(r pact.Router, srv *wristband.Server) { r.GroupRaw("", nil, func(g pact.Router) { g.Get("/.well-known/oauth-authorization-server", srv.Metadata) g.Get("/oauth/mcp/authorize", srv.Authorize) g.Post("/oauth/mcp/token", srv.Token) g.Post("/oauth/mcp/register", srv.Register) }) } // Approve is called by the application's consent handler for a signed-in user. func Approve(ctx context.Context, w http.ResponseWriter, srv *wristband.Server, requestID string, userID uint) error { redirectTo, err := srv.IssueCode(ctx, requestID, userID, []string{"read"}, nil) if err != nil { return err // wristband.ErrPendingNotFound, wristband.ErrNoGrantableScopes, ... } w.Header().Set("Content-Type", "application/json") return json.NewEncoder(w).Encode(map[string]string{"redirect_to": redirectTo}) } ``` ## API reference | Identifier | Description | |------------|-------------| | `wristband.Server` | The authorization server; `wristband.NewServer` builds it from `wristband.Options`. | | `wristband.Server.SetBackend` | Attaches the application's store bundle; handlers that need storage return 500 until it is set. | | `wristband.Server.Metadata` | Handler for `GET /.well-known/oauth-authorization-server`. | | `wristband.Server.Register` | Handler for `POST /oauth/mcp/register` (RFC 7591). | | `wristband.Server.Authorize` | Handler for `GET /oauth/mcp/authorize`. | | `wristband.Server.Token` | Handler for `POST /oauth/mcp/token` (authorization code and refresh token grants). | | `wristband.Server.PendingRequest` | Returns a `wristband.PendingRequestView` for a user's pending request. | | `wristband.Server.IssueCode` | Grants consent and returns the redirect URL carrying the code. | | `wristband.Server.DenyPending` | Refuses consent and returns the `access_denied` redirect URL. | | `wristband.Server.Revoke` | Revokes an access token and its refresh-token lineage. | | `wristband.Options` | Issuer, advertised scopes and auth methods, registration limits, resource indicator and token lifetimes. | | `wristband.DefaultOptions` | Defaults for every option except `wristband.Options.Issuer`. | | `wristband.Backend` | Runs a function inside one transaction with a `wristband.Tx`. | | `wristband.Tx` | Transaction-scoped bundle of the stores and the token issuer. | | `wristband.ClientStore` | Persists `wristband.ClientRecord` rows: lookup, capped create, sweep, consent stamp. | | `wristband.AuthCodeStore` | Persists `wristband.AuthCodeRecord` rows: pending requests and issued codes. | | `wristband.RefreshTokenStore` | Persists `wristband.RefreshTokenRecord` lineage rows, including rotation and lineage revocation. | | `wristband.AccessTokenIssuer` | Mints and revokes the application's access tokens, returning a `wristband.IssuedToken`. | | `wristband.IssueClientCredentials` | Generates a client ID and, for confidential clients, a one-time secret and its hash. | | `wristband.RejectRedirectURI` | Returns why a redirect URI is not acceptable, or an empty string. | | `wristband.ErrPendingNotFound` | The pending request does not exist for this user or is no longer usable. | | `wristband.ErrNoGrantableScopes` | `wristband.Server.IssueCode` was called with no scopes. | | `wristband.ErrClientCapReached` | Returned by `wristband.ClientStore.CreateWithCap` when the client cap is reached. | wristband reads no config keys or environment variables; the application passes a `wristband.Options` value. `wristband.DefaultOptions` sets: | Field | Default | |-------|---------| | `wristband.Options.ServiceDocumentationPath` | `/help` | | `wristband.Options.ScopesSupported` | `read`, `write`, `ai`, `offline_access` | | `wristband.Options.TokenEndpointAuthMethodsSupported` | `none`, `client_secret_post`, `client_secret_basic` | | `wristband.Options.AuthorizationResponseIssParameterSupported` | `true` | | `wristband.Options.DCRClientCap` | `200` | | `wristband.Options.DCRUnconsentedSweepAge` | 24 hours | | `wristband.Options.RegisterMaxBodyBytes` | 65536 | | `wristband.Options.PendingRequestTTL` | 10 minutes | | `wristband.Options.CodeTTL` | 10 minutes | | `wristband.Options.AccessTokenTTL` | 1 hour | | `wristband.Options.RefreshTokenTTL` | 30 days | Always set `wristband.Options.Issuer` and `wristband.Options.Resource` for your deployment. ## Dependencies - SummerCMS modules: none. - Third-party: none. - Standard library: `bytes`, `context`, `crypto/rand`, `crypto/sha256`, `crypto/subtle`, `encoding/base64`, `encoding/hex`, `encoding/json`, `errors`, `fmt`, `net/http`, `net/url`, `strings`, `time`. ## Testing ```sh go test ./modules/wristband/... ``` The tests use in-memory fakes for the stores and need no external services.