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.