feat(11.1-04): add the Database section and the core Services pages
- docs/database: models, migrations, queries and pagination, relations, casts and validation, attachments and transactions (lagoon.Transaction, lagoon.AfterCommit, nested savepoints, lagoon.OnDatabase) - docs/services: configuration, events, routing with auth groups, rate limiting, authentication, the OAuth server, mail and localization - runnable Examples for lagoon, attach, compass, surf, wire, bouncer, wristband, postcard, phrasebook and festival; lagoon TestDocs* regions run on the package's Postgres harness through DocsDB - 15 new required pages
This commit is contained in:
128
docs/services/authentication.md
Normal file
128
docs/services/authentication.md
Normal file
@@ -0,0 +1,128 @@
|
||||
---
|
||||
title: Authentication
|
||||
description: Mint and verify JWTs with bouncer, turn guards into route middleware, revoke tokens through a jti blacklist and hash passwords with bcrypt.
|
||||
section: services
|
||||
order: 50
|
||||
---
|
||||
# Authentication
|
||||
|
||||
WinterCMS reads the current user through the `Auth` and `BackendAuth` facades, and API plugins add a JWT layer on top. SummerCMS has no facades: [bouncer](../../modules/bouncer/README.md) turns a request into a `bouncer.Principal` through a guard, stores it on the request context, and issues and checks the tokens. The frontend user model and its login endpoints belong to the application's user plugin; bouncer supplies the building blocks.
|
||||
|
||||
## Tokens
|
||||
|
||||
bouncer issues HS256 JSON Web Tokens for two audiences: frontend users (`bouncer.AudienceUser`) and admins (`bouncer.AudienceBackend`). `bouncer.Mint` signs a frontend token for a subject, the user ID as a string, and returns the token and its random `jti`. `bouncer.MintAudience` signs one for any audience.
|
||||
|
||||
`bouncer.VerifyClaims` checks the signature with HS256 pinned, requires `exp` and `sub`, and returns the claims; `bouncer.Verify` returns only the subject. Both accept frontend tokens, including older tokens without an audience claim. `bouncer.VerifyClaimsAudience` requires the audience you name, so a frontend token never passes an admin check and the other way round:
|
||||
|
||||
```go src=modules/bouncer/example_test.go#ExampleMint
|
||||
const issuer = "http://127.0.0.1:8080/api/login"
|
||||
token, _, err := bouncer.Mint(testSecret, "42", issuer, time.Hour)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
sub, iat, exp, _, err := bouncer.VerifyClaims(token, testSecret)
|
||||
fmt.Println(sub, exp.Sub(iat), err)
|
||||
|
||||
// A frontend token never passes a backend check, and a wrong secret fails.
|
||||
_, _, _, _, err = bouncer.VerifyClaimsAudience(token, testSecret, bouncer.AudienceBackend)
|
||||
fmt.Println(err != nil)
|
||||
_, err = bouncer.Verify(token, "another-secret-with-at-least-32-bytes")
|
||||
fmt.Println(err != nil)
|
||||
|
||||
// Refresh reissues the token and blacklists the old jti after the grace.
|
||||
bl := bouncer.NewMemoryBlacklist()
|
||||
fresh, err := bouncer.Refresh(testSecret, token, 14*24*time.Hour, bl, 0, issuer)
|
||||
fmt.Println(fresh != token, err)
|
||||
_, _, _, jti, _ := bouncer.VerifyClaims(token, testSecret)
|
||||
revoked, _ := bl.IsBlacklisted(context.Background(), jti)
|
||||
fmt.Println(revoked)
|
||||
// Output:
|
||||
// 42 1h0m0s <nil>
|
||||
// true
|
||||
// true
|
||||
// true <nil>
|
||||
// true
|
||||
```
|
||||
|
||||
The secret in these examples is a test value. In an application, read the signing secret from configuration set through an environment variable, use at least 32 random bytes and never commit it.
|
||||
|
||||
## Refreshing and revoking
|
||||
|
||||
`bouncer.Refresh` reissues a token while its `iat` is inside the refresh window, even when it has expired, and blacklists the old `jti` after a grace period, so requests already in flight with the old token still succeed. `bouncer.RefreshAudienceFor` also reloads the user and refuses one who was deleted, or whose tokens were issued before `bouncer.Principal.TokensValidAfter`, with `bouncer.ErrSubjectRejected`. The rules match the PHP jwt-auth library, so tokens issued by a WinterCMS application keep working after a port.
|
||||
|
||||
Revoked token IDs are kept in a `bouncer.BlacklistStore`:
|
||||
|
||||
- `bouncer.NewMemoryBlacklist` keeps them in the process, for tests.
|
||||
- `bouncer.NewPostgresBlacklist` keeps them in a table you name, with `jti`, `expires_at` and `valid_until` columns. It rejects table names that are not plain identifiers.
|
||||
|
||||
Logging out is blacklisting the token's `jti`. Setting a user's `TokensValidAfter` to now revokes all their tokens at once, for example after a password change.
|
||||
|
||||
## Guards and middleware
|
||||
|
||||
A guard implements `bouncer.Guard`: it turns a request into a principal or an error. `bouncer.NewJWTGuard` is the frontend guard. It reads the bearer token, then any cookie names you give it, verifies the token, checks the blacklist and the user's cutoff, and loads the user through your `bouncer.UserProvider`. On failure it answers 401 with `{"error":true,"message":...}`. `bouncer.NewBackendJWTGuard` is the same for the admin audience.
|
||||
|
||||
Register guards in a `bouncer.Registry` under a name, and turn one into middleware with `bouncer.Registry.Middleware`. The middleware stores the principal on the context, where handlers read it with `bouncer.User`:
|
||||
|
||||
```go src=modules/bouncer/example_test.go#ExampleNewJWTGuard
|
||||
guards := bouncer.NewRegistry()
|
||||
guard := bouncer.NewJWTGuard(testSecret, users{}, bouncer.NewMemoryBlacklist(), "token")
|
||||
if err := guards.Register("acme.blog", "acme.auth", guard); err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
auth, err := guards.Middleware("acme.auth")
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
me := auth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
user, _ := bouncer.User(r.Context())
|
||||
fmt.Fprintf(w, "user %d, locale %s", user.ID, user.PreferredLocale)
|
||||
}))
|
||||
|
||||
token, _, _ := bouncer.Mint(testSecret, "42", "http://127.0.0.1:8080/api/login", time.Hour)
|
||||
for _, set := range []func(*http.Request){
|
||||
func(r *http.Request) {},
|
||||
func(r *http.Request) { r.Header.Set("Authorization", "Bearer "+token) },
|
||||
func(r *http.Request) { r.AddCookie(&http.Cookie{Name: "token", Value: token}) },
|
||||
} {
|
||||
req := httptest.NewRequest("GET", "/api/me", nil)
|
||||
set(req)
|
||||
rec := httptest.NewRecorder()
|
||||
me.ServeHTTP(rec, req)
|
||||
fmt.Println(rec.Code, strings.TrimSpace(rec.Body.String()))
|
||||
}
|
||||
// Output:
|
||||
// 401 {"error":true,"message":"Token not provided"}
|
||||
// 200 user 42, locale pl
|
||||
// 200 user 42, locale pl
|
||||
```
|
||||
|
||||
To protect routes, return that middleware from `pact.HasMiddleware` under a name and put the name on a route group. [Routing](routing.md) shows the complete auth group. A guard that does not implement `bouncer.UnauthorizedWriter` lets an unauthenticated request through without a principal, for routes that behave differently for guests.
|
||||
|
||||
A guard that resolves more than a user, such as an API token record, implements `bouncer.CredentialGuard`; the middleware stores that record too, and handlers read it with `bouncer.Credential`.
|
||||
|
||||
## Passwords
|
||||
|
||||
`bouncer.HashPassword` hashes with bcrypt at the cost you give it, `bouncer.CheckPassword` compares in constant time, and `bouncer.NeedsRehash` reports a hash made below your configured cost. WinterCMS stores bcrypt hashes too, so existing passwords keep working:
|
||||
|
||||
```go src=modules/bouncer/example_test.go#ExampleHashPassword
|
||||
hash, err := bouncer.HashPassword(10, "correct horse battery staple")
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
fmt.Println(bouncer.CheckPassword(hash, "correct horse battery staple"))
|
||||
fmt.Println(bouncer.CheckPassword(hash, "wrong"))
|
||||
// After raising the configured cost, rehash on the next successful login.
|
||||
fmt.Println(bouncer.NeedsRehash(hash, 12))
|
||||
// Output:
|
||||
// true
|
||||
// false
|
||||
// true
|
||||
```
|
||||
|
||||
Rehash a password on the next successful login when `bouncer.NeedsRehash` reports true.
|
||||
|
||||
Admin sign-in, admin permissions and the admin user commands are covered in the Backend section.
|
||||
97
docs/services/configuration.md
Normal file
97
docs/services/configuration.md
Normal file
@@ -0,0 +1,97 @@
|
||||
---
|
||||
title: Configuration
|
||||
description: Read layered configuration with compass, from plugin defaults through per-environment files and SUMMER_ variables to runtime overrides saved to disk.
|
||||
section: services
|
||||
order: 10
|
||||
---
|
||||
# Configuration
|
||||
|
||||
WinterCMS reads configuration with `Config::get('app.name')` from `config/*.php`, per-environment directories and `.env`. SummerCMS reads it from a `compass.Config` built by [compass](../../modules/compass/README.md), with the same dot paths. [Setup, Configuration](../setup/configuration.md) lists the keys an application sets; this page covers how the layers merge and how code reads and changes them.
|
||||
|
||||
## Layers
|
||||
|
||||
`compass.Config` merges its sources in a fixed order. Each layer overrides the ones before it:
|
||||
|
||||
1. Plugin defaults, merged with `compass.Config.MergePlugin` when the plugin is activated. A plugin's `config/config.yaml` becomes `<plugin id>.<key>`, so `acme.blog.posts_per_page` is the WinterCMS `acme.blog::posts_per_page`. Any other `config/<name>.yaml` becomes `<plugin id>.<name>.<key>`.
|
||||
2. `config/*.yaml`. Each file is a section named after the file, so `config/app.yaml` provides `app.*`.
|
||||
3. `config/env/<environment>/*.yaml`, the per-environment sections.
|
||||
4. `SUMMER_` environment variables and the `.env` file next to `config/`. `SUMMER_MAIL__DRIVER` sets `mail.driver`: the prefix is removed, `__` separates the path segments and the name is lower-cased. A `.env` value applies only when the real environment does not set the same variable.
|
||||
5. `config/env/<environment>/overrides.yaml`, written by `compass.Config.Persist`.
|
||||
6. Values set in memory with `compass.Config.Set`.
|
||||
|
||||
The environment comes from `SUMMER_ENV` and defaults to `production`. Its name may contain only letters, digits, `-` and `_`.
|
||||
|
||||
## Reading values
|
||||
|
||||
The typed getters `compass.Config.String`, `compass.Config.Int` and `compass.Config.Bool` return the zero value for a missing key. Use `compass.Config.Lookup` or `compass.Config.Has` when a missing key must be told apart from a zero value, and `compass.Config.LoadSection` to read a whole section into a struct with `koanf` tags:
|
||||
|
||||
```go src=modules/compass/example_test.go#ExampleOpen
|
||||
dir, err := os.MkdirTemp("", "acme-config")
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
if err := writeConfig(dir); err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
|
||||
// Environ stands in for the process environment (nil reads os.Environ).
|
||||
cfg, err := compass.Open(compass.Options{
|
||||
Dir: dir,
|
||||
Env: "development",
|
||||
Environ: []string{"SUMMER_MAIL__DRIVER=smtp"},
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
|
||||
// A plugin's embedded config/config.yaml becomes its defaults.
|
||||
plugin := fstest.MapFS{"config/config.yaml": {Data: []byte("posts_per_page: 10\n")}}
|
||||
if err := cfg.MergePlugin("acme.blog", plugin); err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println(cfg.String("app.name"), cfg.Bool("app.debug"), cfg.Int("acme.blog.posts_per_page"))
|
||||
var mail mailSettings
|
||||
if err := cfg.LoadSection("mail", &mail); err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
fmt.Println(mail.Driver, mail.From)
|
||||
_, found := cfg.Lookup("app.timezone")
|
||||
fmt.Println(cfg.Environment(), found)
|
||||
|
||||
// A runtime override, saved to env/development/overrides.yaml.
|
||||
if err := cfg.Set("acme.blog.posts_per_page", 25); err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
if err := cfg.Persist(); err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
saved, _ := os.ReadFile(filepath.Join(dir, "env", "development", "overrides.yaml"))
|
||||
fmt.Print(string(saved))
|
||||
// Output:
|
||||
// Acme true 10
|
||||
// smtp blog@example.com
|
||||
// development false
|
||||
// acme:
|
||||
// blog:
|
||||
// posts_per_page: 25
|
||||
```
|
||||
|
||||
The application opens its configuration once with `compass.Load("config")` in the generated `main` and hands it to the `backpack.App`. Plugins read it through `app.Config` and never open their own.
|
||||
|
||||
## Changing values at runtime
|
||||
|
||||
`compass.Config.Set` changes a value in memory. `compass.Config.Persist` saves every value set at runtime to `config/env/<environment>/overrides.yaml`, keeping the keys already saved there. It replaces the file atomically, creates it readable only by its owner and refuses any path outside the config directory. `compass.Config.Reload` rereads every source and discards values that were set but not persisted.
|
||||
|
||||
Values that admins edit in the backend are not configuration: settings pages store them in a database row.
|
||||
|
||||
> [!WARNING]
|
||||
> `overrides.yaml` is written by the running application. Keep it out of version control, and keep secrets in environment variables rather than in values the application persists.
|
||||
98
docs/services/events.md
Normal file
98
docs/services/events.md
Normal file
@@ -0,0 +1,98 @@
|
||||
---
|
||||
title: Events
|
||||
description: Listen for and fire typed events on the application bus with festival, with priorities, collected results and a halting fire that stops when handled.
|
||||
section: services
|
||||
order: 20
|
||||
---
|
||||
# Events
|
||||
|
||||
`Event::listen` and `Event::fire` are how WinterCMS plugins extend each other. SummerCMS keeps the pattern with [festival](../../modules/festival/README.md): each application has one bus, `backpack.App.Events`, and plugins listen from their `Boot` step for events that other plugins fire. [Extending plugins](../plugins/extending.md) shows where events fit among the other extension points; this page covers the bus itself.
|
||||
|
||||
## Typed events
|
||||
|
||||
An event is a Go type, not a string. A listener is a function that takes a context and the event, and the bus routes by type, so a listener never receives a payload of the wrong shape and a mismatch does not compile. Name events after what happened, and keep them in the package of the plugin that fires them so listeners can import the type.
|
||||
|
||||
`festival.Bus.Listen` registers a listener at priority 0 and `festival.Bus.ListenPriority` at a given priority. Higher priorities run first, and listeners with the same priority run in registration order. The first argument is the ID of the plugin that owns the listener:
|
||||
|
||||
```go src=modules/festival/example_test.go#ExampleBus_Fire
|
||||
bus := festival.New() // in a plugin, use app.Events
|
||||
|
||||
// acme.search and acme.notify extend acme.blog from their Boot steps.
|
||||
bus.Listen("acme.search", func(ctx context.Context, e PostPublished) error {
|
||||
fmt.Println("index", e.Title)
|
||||
return nil
|
||||
})
|
||||
bus.ListenPriority("acme.notify", 10, func(ctx context.Context, e PostPublished) error {
|
||||
fmt.Println("notify subscribers of", e.Title)
|
||||
return nil
|
||||
})
|
||||
|
||||
// acme.blog fires the event; higher priorities run first.
|
||||
if err := bus.Fire(context.Background(), PostPublished{Title: "Hello"}); err != nil {
|
||||
fmt.Println(err)
|
||||
}
|
||||
// Output:
|
||||
// notify subscribers of Hello
|
||||
// index Hello
|
||||
```
|
||||
|
||||
`festival.Bus.Fire` runs every listener, even after one fails, and returns the failures joined with `errors.Join`. A listener that panics is recovered and reported as an error that names its plugin, so one faulty plugin cannot stop the others.
|
||||
|
||||
All three dispatch methods run the listeners on the caller's goroutine, before they return. For work that should not delay the request, a listener dispatches a job; see [Queued jobs](jobs.md).
|
||||
|
||||
## Collecting contributions
|
||||
|
||||
WinterCMS events often gather something from their listeners, such as extra form fields or menu items. `festival.Bus.Collect` runs every listener and, after each one, merges the map the event returns from `festival.Collectable.Collected`. A later listener wins when two set the same key. Use a pointer event so listeners can write to it:
|
||||
|
||||
```go src=modules/festival/example_test.go#ExampleBus_Collect
|
||||
bus := festival.New()
|
||||
bus.Listen("acme.seo", func(ctx context.Context, e *PostFormExtended) error {
|
||||
e.fields = map[string]any{"meta_title": "text"}
|
||||
return nil
|
||||
})
|
||||
bus.Listen("acme.gallery", func(ctx context.Context, e *PostFormExtended) error {
|
||||
e.fields = map[string]any{"cover": "fileupload"}
|
||||
return nil
|
||||
})
|
||||
|
||||
fields, err := bus.Collect(context.Background(), &PostFormExtended{})
|
||||
fmt.Println(fields, err)
|
||||
// Output: map[cover:fileupload meta_title:text] <nil>
|
||||
```
|
||||
|
||||
`festival.Bus.Collect` returns the payload gathered so far together with the joined errors, so one failing listener does not lose the others' contributions.
|
||||
|
||||
## Stopping at the first handler
|
||||
|
||||
The WinterCMS halting fire stops at the first listener that returns a result. `festival.Bus.UntilHandled` stops as soon as the event's `festival.Handleable.IsHandled` reports true, or at the first error, and returns whether the event was handled:
|
||||
|
||||
```go src=modules/festival/example_test.go#ExampleBus_UntilHandled
|
||||
bus := festival.New()
|
||||
bus.ListenPriority("acme.pages", 10, func(ctx context.Context, e *SlugResolving) error {
|
||||
if e.Slug == "about" {
|
||||
e.Found = "page"
|
||||
}
|
||||
return nil
|
||||
})
|
||||
bus.Listen("acme.blog", func(ctx context.Context, e *SlugResolving) error {
|
||||
fmt.Println("acme.blog asked for", e.Slug)
|
||||
e.Found = "post"
|
||||
return nil
|
||||
})
|
||||
|
||||
for _, slug := range []string{"about", "hello-world"} {
|
||||
e := &SlugResolving{Slug: slug}
|
||||
handled, err := bus.UntilHandled(context.Background(), e)
|
||||
fmt.Println(slug, handled, e.Found, err)
|
||||
}
|
||||
// Output:
|
||||
// about true page <nil>
|
||||
// acme.blog asked for hello-world
|
||||
// hello-world true post <nil>
|
||||
```
|
||||
|
||||
Here `acme.pages` listens at a higher priority, so it gets the first chance to claim a slug, and `acme.blog` is asked only when no page matched.
|
||||
|
||||
## Events and transactions
|
||||
|
||||
Listeners run where the event is fired, inside any transaction the caller has open. A listener that writes to the database joins that transaction when it uses the transaction handle the event carries. A listener with a side effect outside the database, such as a mail or a broadcast, should defer it with `lagoon.AfterCommit`, so it does not announce a write that rolls back. See [Transactions](../database/transactions.md).
|
||||
84
docs/services/localization.md
Normal file
84
docs/services/localization.md
Normal file
@@ -0,0 +1,84 @@
|
||||
---
|
||||
title: Localization
|
||||
description: Ship plugin translations in lang YAML catalogs, translate with :name placeholders and CLDR plurals through phrasebook, and read the request locale.
|
||||
section: services
|
||||
order: 80
|
||||
---
|
||||
# Localization
|
||||
|
||||
WinterCMS plugins keep their strings in `lang/<locale>/*.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](../../modules/phrasebook/README.md) loads the catalogs and translates.
|
||||
|
||||
## Catalogs
|
||||
|
||||
A plugin ships `lang/<locale>/<group>.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/<locale>/<namespace>/<group>.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 src=modules/phrasebook/example_test.go#ExampleTranslator_Get
|
||||
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 src=modules/phrasebook/example_test.go#ExampleTranslator_Choice
|
||||
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](../../modules/surf/README.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.
|
||||
102
docs/services/mail.md
Normal file
102
docs/services/mail.md
Normal file
@@ -0,0 +1,102 @@
|
||||
---
|
||||
title: Mail
|
||||
description: Ship WinterCMS-style mail templates in a plugin, send them through postcard, and deliver them with the memory, log or SMTP driver.
|
||||
section: services
|
||||
order: 70
|
||||
---
|
||||
# Mail
|
||||
|
||||
WinterCMS plugins ship mail templates in `views/mail` and send them with `Mail::send`. SummerCMS keeps the file format and the dotted template names; [postcard](../../modules/postcard/README.md) loads them at boot and sends them through a configured driver.
|
||||
|
||||
## Templates and layouts
|
||||
|
||||
A plugin implements `pact.HasMailTemplates`: it returns its embedded `views/mail` files, the template names it ships and short aliases for its layouts. A template named `acme.blog::mail.welcome` lives in `views/mail/welcome.htm` (dots in the name become directories), and a plugin may only register names in its own namespace. A missing file, a duplicate name or an unknown layout alias fails the start-up.
|
||||
|
||||
The file format is WinterCMS's: an INI header with the `subject`, the `layout` alias and a `description`, a `==` line, then a Markdown body with Go template variables such as `{{ .name }}`. A layout has a header, a text wrapper and an HTML wrapper, separated by `==` lines, each with `{{ .Content }}` where the message goes. A neutral `default` layout is built in.
|
||||
|
||||
Each message gets an HTML part, rendered from the Markdown, and a plain-text part. `mail.css` and `mail.brandCss` are inlined into the layout's style block.
|
||||
|
||||
## Sending
|
||||
|
||||
At boot, `postcard.Activate` publishes one `postcard.Mailer` on the application, and `postcard.BootPlugin` registers each plugin's templates as it boots. Look the mailer up with `app.Lookup[postcard.Mailer]()` and send a `postcard.Message` with the template name, the recipients and the variables. Tests build the catalog and a memory driver directly, and read back what was sent:
|
||||
|
||||
```go src=modules/postcard/example_test.go#ExampleMailer_Send
|
||||
// At boot, postcard.BootPlugin registers what pact.HasMailTemplates
|
||||
// declares; a test registers the same thing directly.
|
||||
cat := postcard.NewCatalog()
|
||||
err := cat.Register("acme.blog", mailFS,
|
||||
[]string{"acme.blog::mail.welcome"},
|
||||
map[string]string{"blog": "acme.blog::mail.layouts.blog"})
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
driver := postcard.NewMemoryDriver() // mail.driver: memory
|
||||
mailer := postcard.NewMailer(cat, driver, postcard.Options{From: "blog@example.com"})
|
||||
|
||||
err = mailer.Send(context.Background(), postcard.Message{
|
||||
Template: "acme.blog::mail.welcome",
|
||||
To: []string{"ada@example.com"},
|
||||
Vars: map[string]any{"name": "Ada"},
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
sent := driver.Messages()[0]
|
||||
fmt.Println(sent.From, sent.To, sent.Subject)
|
||||
fmt.Println(sent.Text)
|
||||
fmt.Println(strings.Contains(sent.HTML, `<div class="blog-mail"><p>Hi <strong>Ada</strong>`))
|
||||
|
||||
// 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](jobs.md) and [Transactions](../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: <secret>
|
||||
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`.
|
||||
148
docs/services/oauth-server.md
Normal file
148
docs/services/oauth-server.md
Normal file
@@ -0,0 +1,148 @@
|
||||
---
|
||||
title: OAuth server
|
||||
description: Let MCP clients act for your users with the wristband OAuth server, covering metadata, dynamic client registration, PKCE, consent and refresh token rotation.
|
||||
section: services
|
||||
order: 60
|
||||
---
|
||||
# OAuth server
|
||||
|
||||
[wristband](../../modules/wristband/README.md) is the protocol side of an OAuth 2 authorization server, built for MCP clients such as AI assistants and connectors that act on behalf of the application's users. WinterCMS core has no counterpart. wristband provides the HTTP handlers and the consent operations; the application provides the storage, the access tokens it already uses for its API, and the consent screen.
|
||||
|
||||
## What it implements
|
||||
|
||||
- The RFC 8414 metadata document, advertising the endpoints, the `authorization_code` and `refresh_token` grants, S256 PKCE and the RFC 9207 `iss` response parameter.
|
||||
- RFC 7591 dynamic client registration: public clients (`none`) and confidential ones (`client_secret_post`, `client_secret_basic`), redirect URI validation, a cap on unrevoked clients and a sweep of old clients that never got consent.
|
||||
- The authorization endpoint, which validates the client and its exact registered redirect URI before it redirects anywhere, then checks PKCE, the client's scopes and the RFC 8707 `resource` value, stores a pending request and sends the browser to the application's consent page.
|
||||
- The token endpoint: code exchange with PKCE verification, then an access token from the application and a rotating refresh token. Reusing a spent refresh token revokes its whole lineage and the access tokens issued from it.
|
||||
|
||||
Client secrets, codes and refresh tokens are random strings stored only as SHA-256 hashes and compared in constant time.
|
||||
|
||||
## Configuring the server
|
||||
|
||||
wristband reads no configuration keys. The application builds a `wristband.Options` value from `wristband.DefaultOptions` and sets at least `wristband.Options.Issuer`, its own URL without a trailing slash, and `wristband.Options.Resource`, the URL of the protected resource its tokens are for:
|
||||
|
||||
```go src=modules/wristband/example_test.go#newServer
|
||||
// newServer builds the authorization server of an application served at
|
||||
// https://blog.example.com. The application sets Issuer and Resource for
|
||||
// its own deployment; the defaults cover everything else.
|
||||
func newServer() *wristband.Server {
|
||||
opts := wristband.DefaultOptions()
|
||||
opts.Issuer = "https://blog.example.com"
|
||||
opts.Resource = "https://blog.example.com/mcp"
|
||||
opts.ScopesSupported = []string{"read", "write", "offline_access"}
|
||||
return wristband.NewServer(opts)
|
||||
}
|
||||
```
|
||||
|
||||
> [!WARNING]
|
||||
> Always set `wristband.Options.Resource` for your deployment. Do not rely on the value `wristband.DefaultOptions` returns.
|
||||
|
||||
The defaults cover the rest: pending requests and codes live 10 minutes, access tokens 1 hour and refresh tokens 30 days, at most 200 unrevoked clients may register, and registration bodies are capped at 64 KiB. The metadata document serves the configured values:
|
||||
|
||||
```go src=modules/wristband/example_test.go#ExampleServer_Metadata
|
||||
srv := newServer()
|
||||
// srv.SetBackend(backend) attaches the application's stores; the
|
||||
// metadata document does not need them.
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Metadata(rec, httptest.NewRequest("GET", "/.well-known/oauth-authorization-server", nil))
|
||||
|
||||
var doc map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &doc); err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
for _, key := range []string{
|
||||
"issuer",
|
||||
"authorization_endpoint",
|
||||
"token_endpoint",
|
||||
"registration_endpoint",
|
||||
"scopes_supported",
|
||||
"grant_types_supported",
|
||||
"code_challenge_methods_supported",
|
||||
} {
|
||||
fmt.Println(key, doc[key])
|
||||
}
|
||||
// Output:
|
||||
// issuer https://blog.example.com
|
||||
// authorization_endpoint https://blog.example.com/oauth/mcp/authorize
|
||||
// token_endpoint https://blog.example.com/oauth/mcp/token
|
||||
// registration_endpoint https://blog.example.com/oauth/mcp/register
|
||||
// scopes_supported [read write offline_access]
|
||||
// grant_types_supported [authorization_code refresh_token]
|
||||
// code_challenge_methods_supported [S256]
|
||||
```
|
||||
|
||||
## Storage
|
||||
|
||||
The server has no database code. The application attaches its storage with `wristband.Server.SetBackend`; until it does, handlers that need storage answer 500. A `wristband.Backend` runs a function inside one database transaction with a `wristband.Tx`, which bundles the stores and the token issuer:
|
||||
|
||||
| Interface | Stores |
|
||||
|-----------|--------|
|
||||
| `wristband.ClientStore` | Registered clients (`wristband.ClientRecord`): lookup, capped create, the sweep and the consent stamp. |
|
||||
| `wristband.AuthCodeStore` | Pending requests and issued codes (`wristband.AuthCodeRecord`). |
|
||||
| `wristband.RefreshTokenStore` | Refresh token lineages (`wristband.RefreshTokenRecord`), including rotation and revocation. |
|
||||
| `wristband.AccessTokenIssuer` | Mints and revokes the application's own API tokens. |
|
||||
|
||||
Registration, code exchange and refresh each run in one transaction through this interface, so the writes of each step commit or roll back together: a code is never marked used without its tokens, and a refresh token is never spent without its replacement.
|
||||
|
||||
## Routes
|
||||
|
||||
Mount the handlers in a raw group, because the OAuth endpoints define their own response formats and must not be wrapped in the JSON envelope middleware (see [Routing](routing.md)):
|
||||
|
||||
| Route | Handler |
|
||||
|-------|---------|
|
||||
| `GET /.well-known/oauth-authorization-server` | `wristband.Server.Metadata` |
|
||||
| `GET /oauth/mcp/authorize` | `wristband.Server.Authorize` |
|
||||
| `POST /oauth/mcp/token` | `wristband.Server.Token` |
|
||||
| `POST /oauth/mcp/register` | `wristband.Server.Register` |
|
||||
|
||||
The metadata document advertises these paths under the issuer, so mount them at exactly these paths.
|
||||
|
||||
## Consent
|
||||
|
||||
The authorization endpoint sends the browser to `<issuer>/connect?request=<id>`. That page belongs to the application: it signs the user in, shows what the client asks for and posts the decision to the application's own consent handler, which calls:
|
||||
|
||||
- `wristband.Server.PendingRequest` to read what to show, as a `wristband.PendingRequestView`;
|
||||
- `wristband.Server.IssueCode` to grant, which returns the redirect URL carrying the code, `iss` and `state`;
|
||||
- `wristband.Server.DenyPending` to refuse, which returns the `access_denied` redirect URL.
|
||||
|
||||
`wristband.Server.IssueCode` stores exactly the scopes it is given. The consent handler must pass only scopes that the pending request asked for, the user accepted and the application can grant. A missing, foreign, used or expired request is `wristband.ErrPendingNotFound` in every case, so the handler cannot tell another user's request ID from an invalid one.
|
||||
|
||||
`wristband.Server.Revoke` disconnects an app: it revokes an access token and the refresh lineage behind it.
|
||||
|
||||
## Clients created outside registration
|
||||
|
||||
Operator tooling that creates clients directly uses the same rules as registration. `wristband.RejectRedirectURI` accepts `https://` URIs and loopback `http://` URIs only:
|
||||
|
||||
```go src=modules/wristband/example_test.go#ExampleRejectRedirectURI
|
||||
for _, uri := range []string{
|
||||
"https://client.example.org/callback",
|
||||
"http://127.0.0.1:33418/callback",
|
||||
"http://client.example.org/callback",
|
||||
} {
|
||||
if reason := wristband.RejectRedirectURI(uri); reason != "" {
|
||||
fmt.Println("rejected:", reason)
|
||||
continue
|
||||
}
|
||||
fmt.Println("accepted:", uri)
|
||||
}
|
||||
// Output:
|
||||
// accepted: https://client.example.org/callback
|
||||
// accepted: http://127.0.0.1:33418/callback
|
||||
// rejected: Redirect URI must be https:// or loopback http://127.0.0.1 / http://localhost: http://client.example.org/callback
|
||||
```
|
||||
|
||||
`wristband.IssueClientCredentials` generates the client ID and, for a confidential client, a secret that is returned once and its hash, which is what you store:
|
||||
|
||||
```go src=modules/wristband/example_test.go#ExampleIssueClientCredentials
|
||||
// A confidential client gets a secret, shown once; store only the hash.
|
||||
id, secret, hash, err := wristband.IssueClientCredentials("client_secret_post")
|
||||
fmt.Println(id != "", secret != "", hash != nil && *hash != secret, err)
|
||||
|
||||
// A public client (PKCE only) gets no secret.
|
||||
_, secret, hash, err = wristband.IssueClientCredentials("none")
|
||||
fmt.Println(secret == "", hash == nil, err)
|
||||
// Output:
|
||||
// true true true <nil>
|
||||
// true true <nil>
|
||||
```
|
||||
73
docs/services/rate-limiting.md
Normal file
73
docs/services/rate-limiting.md
Normal file
@@ -0,0 +1,73 @@
|
||||
---
|
||||
title: Rate limiting
|
||||
description: Throttle routes with inline limits or named buckets, key them by user or client IP, and trust X-Forwarded-For only from configured proxies.
|
||||
section: services
|
||||
order: 40
|
||||
---
|
||||
# Rate limiting
|
||||
|
||||
Laravel limits requests with the `throttle` middleware and named limiters from `RateLimiter::for`. SummerCMS has both through [surf](../../modules/surf/README.md): a `throttle` middleware that takes an inline limit or the name of a bucket a plugin declares.
|
||||
|
||||
## Inline limits
|
||||
|
||||
`throttle:<max>,<minutes>` allows `max` requests per window of `minutes` minutes. The counter is kept per signed-in user, or per client IP for guests. The plugin on [Routing](routing.md) puts `throttle:60,1` on its whole `/api/blog` group, so each client may make 60 requests a minute to it.
|
||||
|
||||
## Named buckets
|
||||
|
||||
A bucket gives a limit its own key, such as the client IP plus the route, or a token ID. A plugin declares buckets by implementing `surf.BucketProvider`, and routes name them as `throttle:<bucket>`:
|
||||
|
||||
```go src=modules/surf/example_test.go#BlogPlugin.Buckets
|
||||
// Buckets declares a named rate limit, used as throttle:blog.comments.
|
||||
func (p *BlogPlugin) Buckets() map[string]surf.Bucket {
|
||||
return map[string]surf.Bucket{
|
||||
"blog.comments": {
|
||||
Max: 1,
|
||||
Decay: time.Minute,
|
||||
Key: func(r *http.Request) string { return "comments|" + surf.ClientIP(r, p.trusted) },
|
||||
},
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Each `surf.Bucket` has the maximum number of requests, the window length (`Decay`) and a `Key` function that builds the counter key from the request. Prefix the key with the bucket's purpose, as above, so two buckets never share a counter. A route may name several throttles; each counts separately. A throttle naming a bucket that no plugin declares fails the start-up.
|
||||
|
||||
A request over the limit gets a 429 response with the body `{"message":"Too Many Attempts."}` and a `Retry-After` header. Every throttled response carries `X-RateLimit-Limit` and `X-RateLimit-Remaining`, and a rejected one also `X-RateLimit-Reset`.
|
||||
|
||||
The limiter is a fixed window: the counter resets when the window ends, as Laravel's cache limiter does. Counters live in the process (`surf.MemoryStore`, behind the `surf.Store` interface), so each application instance counts on its own. Behind a load balancer with several instances, the effective limit is the configured limit times the number of instances.
|
||||
|
||||
## Client IP and trusted proxies
|
||||
|
||||
`surf.ClientIP` is the one place the client IP comes from. It uses the connection's remote address, and reads `X-Forwarded-For` only when that address is inside a range listed in `http.trusted_proxies`. It then takes the rightmost address that is not itself a trusted proxy, so a client cannot choose its own IP by sending the header:
|
||||
|
||||
```go src=modules/surf/example_test.go#ExampleClientIP
|
||||
cfg, err := compass.Open(compass.Options{Dir: "config", Env: "development", Environ: []string{}})
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
_ = cfg.Set("http.trusted_proxies", []string{"10.0.0.0/8"})
|
||||
trusted := surf.TrustedProxies(cfg)
|
||||
|
||||
// Through the load balancer at 10.0.0.5: the forwarded client is used.
|
||||
viaProxy := httptest.NewRequest("GET", "/api/blog/posts", nil)
|
||||
viaProxy.RemoteAddr = "10.0.0.5:4711"
|
||||
viaProxy.Header.Set("X-Forwarded-For", "198.51.100.23, 10.0.0.9")
|
||||
fmt.Println(surf.ClientIP(viaProxy, trusted))
|
||||
|
||||
// Straight from the internet: a forged header is ignored.
|
||||
direct := httptest.NewRequest("GET", "/api/blog/posts", nil)
|
||||
direct.RemoteAddr = "203.0.113.7:5000"
|
||||
direct.Header.Set("X-Forwarded-For", "127.0.0.1")
|
||||
fmt.Println(surf.ClientIP(direct, trusted))
|
||||
// Output:
|
||||
// 198.51.100.23
|
||||
// 203.0.113.7
|
||||
```
|
||||
|
||||
List only the proxies you run, in `config/http.yaml`:
|
||||
|
||||
```yaml
|
||||
trusted_proxies: ["10.0.0.0/8"]
|
||||
```
|
||||
|
||||
With the list empty (the default), the header is ignored and every request behind a proxy has the proxy's IP. A malformed entry is skipped. A bucket's `Key` function should use the same trusted list, as the plugin above does by reading `surf.TrustedProxies` in `Register`.
|
||||
210
docs/services/routing.md
Normal file
210
docs/services/routing.md
Normal file
@@ -0,0 +1,210 @@
|
||||
---
|
||||
title: Routing
|
||||
description: Declare a plugin's HTTP routes with groups, auth groups, path constraints and named middleware through pact.HasRoutes, and write JSON responses with wire.
|
||||
section: services
|
||||
order: 30
|
||||
---
|
||||
# Routing
|
||||
|
||||
A WinterCMS plugin declares its routes in `routes.php` with `Route::group`, `->middleware()` and `->where()`. A SummerCMS plugin implements `pact.HasRoutes`: its `Routes` method receives a `pact.Router` with the same builder shape. [surf](../../modules/surf/README.md) collects every plugin's routes into one standard library `http.ServeMux`, and checks all of them when the application starts, so a duplicate route, an unknown middleware name or a malformed throttle stops the start-up instead of failing on the first request.
|
||||
|
||||
Handlers are ordinary `http.HandlerFunc` values. There are no controllers to extend and no request objects to learn.
|
||||
|
||||
## Declaring routes
|
||||
|
||||
This plugin declares public routes, an auth group, path constraints and per-route middleware:
|
||||
|
||||
```go src=modules/surf/example_test.go#BlogPlugin.Routes
|
||||
// Routes is the Go form of the plugin's routes.php.
|
||||
func (p *BlogPlugin) Routes(r pact.Router) error {
|
||||
r.Group("/api/blog", surf.Use("throttle:60,1"), func(g pact.Router) {
|
||||
g.Get("/posts/{id}", showPost)
|
||||
g.Where("id", `[0-9]+`)
|
||||
g.Get("/posts/{status}/list", listPosts)
|
||||
g.WhereIn("status", "draft", "published")
|
||||
|
||||
// An auth group: every route inside needs a signed-in user.
|
||||
g.Group("", surf.Use("acme.auth"), func(auth pact.Router) {
|
||||
auth.Get("/me", showMe)
|
||||
auth.Post("/posts/{id}/comments", addComment, "throttle:blog.comments", "body.limit:65536")
|
||||
})
|
||||
})
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
- `pact.Router.Group` adds a path prefix and a middleware list to the routes declared inside it; groups nest. `surf.Use` builds the list.
|
||||
- `pact.Router.Get`, `pact.Router.Post`, `pact.Router.Put`, `pact.Router.Patch` and `pact.Router.Delete` take a path in Go's pattern syntax (`/posts/{id}`) and optional middleware names for that route alone.
|
||||
- `pact.Router.Where` restricts a path parameter of the route declared just before it to a regular expression matched against the whole segment, and `pact.Router.WhereIn` to a list of values. A request that fails a constraint gets a 404.
|
||||
|
||||
In a handler, `r.PathValue("status")` reads a parameter, and `surf.IntParam` reads one as a positive integer:
|
||||
|
||||
```go src=modules/surf/example_test.go#showPost
|
||||
func showPost(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := surf.IntParam(r, "id")
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
wire.WriteJSON(w, http.StatusOK, map[string]any{"id": id})
|
||||
}
|
||||
```
|
||||
|
||||
## Auth groups
|
||||
|
||||
An auth group is a group whose middleware list names a guard. The plugin above turns a [bouncer](../../modules/bouncer/README.md) JWT guard into named middleware and returns it from `pact.HasMiddleware`:
|
||||
|
||||
```go src=modules/surf/example_test.go#BlogPlugin.Middlewares
|
||||
// Middlewares registers the plugin's named middleware: here, a JWT guard
|
||||
// that answers 401 when the request has no valid token.
|
||||
func (p *BlogPlugin) Middlewares() map[string]pact.Middleware {
|
||||
guards := bouncer.NewRegistry()
|
||||
guard := bouncer.NewJWTGuard(secret, users{}, bouncer.NewMemoryBlacklist())
|
||||
if err := guards.Register(p.ID(), "acme.auth", guard); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
auth, err := guards.Middleware("acme.auth")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return map[string]pact.Middleware{"acme.auth": auth}
|
||||
}
|
||||
```
|
||||
|
||||
Every route in the group then requires a valid token, and handlers read the signed-in user with `bouncer.User`. The guard answers 401 with a JSON body when the token is missing or invalid. See [Authentication](authentication.md) for guards and tokens.
|
||||
|
||||
The admin API uses the built-in `backend` middleware name, which the framework registers when the admin is enabled.
|
||||
|
||||
## Middleware
|
||||
|
||||
Named middleware is any `func(http.Handler) http.Handler` a plugin returns from `pact.HasMiddleware`. A plugin that needs a parameter, used as `name:param`, returns a factory from `pact.HasMiddlewareFactories`. Middleware names are global, so prefix them with the plugin: `acme.auth`, `blog.no-store`. A duplicate name fails the start-up.
|
||||
|
||||
The framework registers these names:
|
||||
|
||||
| Name | Does |
|
||||
|------|------|
|
||||
| `throttle:<bucket>` or `throttle:<max>,<minutes>` | Rate limiting; see [Rate limiting](rate-limiting.md). |
|
||||
| `body.limit:<bytes>` | Replaces the default request body limit for the route. |
|
||||
| `locale.from-principal` | Switches the request locale to the signed-in user's preferred locale. |
|
||||
| `backend` | The admin guard, when the admin is enabled. |
|
||||
|
||||
Every route also gets, around its own middleware, JSON panic recovery, the request locale from `Accept-Language`, the body limit from `http.body_limits.default_bytes`, and CORS headers when its path matches `http.cors.paths`. The order is described in [Request lifecycle](../architecture/request-lifecycle.md).
|
||||
|
||||
`pact.Router.GroupRaw` declares a raw group for routes that must not be wrapped in the house JSON middleware, such as webhooks, file streams or the OAuth endpoints: the default body limit is skipped, and a panic returns a bare 500.
|
||||
|
||||
## Responses
|
||||
|
||||
Write JSON with `wire.WriteJSON`. It produces what PHP's `json_encode` produces: HTML characters are not escaped and there is no trailing newline. `wire.Time` marshals a timestamp as Carbon does (`+00:00`, never `Z`), `wire.TriBool` is a nullable boolean, and `wire.Slice` turns a nil slice into `[]`:
|
||||
|
||||
```go src=modules/wire/example_test.go#ExampleWriteJSON
|
||||
var tags []string // nil: the post has no tags
|
||||
warsaw := time.FixedZone("CEST", 2*60*60)
|
||||
body := postJSON{
|
||||
ID: 1,
|
||||
Title: "Tips & <tricks>",
|
||||
Tags: wire.Slice(tags),
|
||||
Featured: wire.TriBool{},
|
||||
Pinned: wire.TriBool{Value: true, Valid: true},
|
||||
PublishedAt: wire.Time{Time: time.Date(2026, 9, 30, 14, 5, 0, 0, warsaw)},
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
wire.WriteJSON(rec, http.StatusOK, map[string]any{"data": body})
|
||||
fmt.Println(rec.Code, rec.Header().Get("Content-Type"))
|
||||
fmt.Printf("%s|\n", rec.Body.String())
|
||||
|
||||
rec = httptest.NewRecorder()
|
||||
wire.WriteOpaque500(rec)
|
||||
fmt.Println(rec.Code, rec.Body.String())
|
||||
// Output:
|
||||
// 200 application/json
|
||||
// {"data":{"id":1,"title":"Tips & <tricks>","tags":[],"featured":null,"pinned":true,"published_at":"2026-09-30T12:05:00+00:00"}}|
|
||||
// 500 {"error":true,"message":"Internal server error"}
|
||||
```
|
||||
|
||||
`wire.WriteOpaque500` writes the fixed 500 body that panic recovery also uses; it reveals nothing about the failure.
|
||||
|
||||
## Testing and listing routes
|
||||
|
||||
`surf.Assemble` builds the complete handler from the application and its plugins, so a test can drive it with `net/http/httptest`:
|
||||
|
||||
```go src=modules/surf/example_test.go#ExampleAssemble
|
||||
// The application passes its config; http.body_limits is required there.
|
||||
app := backpack.New(nil)
|
||||
plugin := &BlogPlugin{}
|
||||
if err := plugin.Register(app); err != nil { // the runtime calls Register
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
h, err := surf.Assemble(app, []party.Plugin{plugin})
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
token, _, _ := bouncer.Mint(secret, "42", "http://127.0.0.1:8080/api/login", time.Hour)
|
||||
|
||||
do := func(method, path string, auth bool) {
|
||||
req := httptest.NewRequest(method, path, strings.NewReader("{}"))
|
||||
if auth {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
fmt.Println(method, path, rec.Code, strings.TrimSpace(rec.Body.String()))
|
||||
}
|
||||
do("GET", "/api/blog/posts/7", false)
|
||||
do("GET", "/api/blog/posts/seven", false)
|
||||
do("GET", "/api/blog/posts/draft/list", false)
|
||||
do("GET", "/api/blog/posts/deleted/list", false)
|
||||
do("GET", "/api/blog/me", false)
|
||||
do("GET", "/api/blog/me", true)
|
||||
do("POST", "/api/blog/posts/7/comments", true)
|
||||
do("POST", "/api/blog/posts/7/comments", true)
|
||||
// Output:
|
||||
// GET /api/blog/posts/7 200 {"id":7}
|
||||
// GET /api/blog/posts/seven 404 404 page not found
|
||||
// GET /api/blog/posts/draft/list 200 {"data":[],"status":"draft"}
|
||||
// GET /api/blog/posts/deleted/list 404 404 page not found
|
||||
// GET /api/blog/me 401 {"error":true,"message":"Token not provided"}
|
||||
// GET /api/blog/me 200 {"id":42}
|
||||
// POST /api/blog/posts/7/comments 201 {"created":true}
|
||||
// POST /api/blog/posts/7/comments 429 {"message":"Too Many Attempts."}
|
||||
```
|
||||
|
||||
`route:list` builds the router the way `serve` does, without opening the database or listening, and prints every route with its plugin and middleware:
|
||||
|
||||
```sh
|
||||
./bin/acme route:list
|
||||
```
|
||||
|
||||
`surf.BuildRouter` returns the same information to Go code through `surf.Router.Routes`:
|
||||
|
||||
```go src=modules/surf/example_test.go#ExampleBuildRouter
|
||||
r, err := surf.BuildRouter(backpack.New(nil), []party.Plugin{&BlogPlugin{}})
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
for _, rt := range r.Routes() {
|
||||
fmt.Println(rt.Method, rt.Pattern, rt.PluginID, rt.Middleware)
|
||||
}
|
||||
// Output:
|
||||
// GET /api/blog/posts/{id} acme.blog [throttle:60,1]
|
||||
// GET /api/blog/posts/{status}/list acme.blog [throttle:60,1]
|
||||
// GET /api/blog/me acme.blog [throttle:60,1 acme.auth]
|
||||
// POST /api/blog/posts/{id}/comments acme.blog [throttle:60,1 acme.auth throttle:blog.comments body.limit:65536]
|
||||
```
|
||||
|
||||
## CORS
|
||||
|
||||
CORS is configured with the keys of Laravel's `config/cors.php`, under `http.cors`, and applies only to paths that match `http.cors.paths`. With no `http.cors` section, no CORS headers are sent:
|
||||
|
||||
```yaml
|
||||
cors:
|
||||
paths: ["api/*"]
|
||||
allowed_origins: ["https://blog.example.com"]
|
||||
allowed_methods: ["*"]
|
||||
allowed_headers: ["*"]
|
||||
supports_credentials: true
|
||||
```
|
||||
|
||||
This fragment belongs in `config/http.yaml`. List the frontend's exact origin; `*` is for public, credential-free APIs only.
|
||||
Reference in New Issue
Block a user