docs(modules): rewrite wristband, bouncer, surf, bonfire, phrasebook, postcard READMEs
This commit is contained in:
@@ -1,3 +1,105 @@
|
||||
# bonfire
|
||||
|
||||
`bonfire` adapts framework and plugin commands to Cobra, with typed command input, flags, and output helpers. The Summer CLI, `cabana`, `lagoon`, and Fonoteka plugins import it; begin with `bonfire.NewRoot` in `root.go`.
|
||||
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 (`<name>` 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.
|
||||
|
||||
## 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)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 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. |
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -1,3 +1,116 @@
|
||||
# bouncer
|
||||
|
||||
`bouncer` supplies authentication guards, JWT minting and verification, password helpers, and blacklist storage. `surf`, `cabana`, and Fonoteka API plugins import it to protect routes; use `bouncer.NewJWTGuard` in `jwt.go`.
|
||||
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":...}`) 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.
|
||||
|
||||
@@ -1,3 +1,119 @@
|
||||
# phrasebook
|
||||
|
||||
`phrasebook` loads and translates plugin locale messages while preserving the application's requested locale. `party`, `cabana`, `lagoon`, and Fonoteka controllers import it; use `phrasebook.Translator` from `translator.go`.
|
||||
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/<locale>/<group>.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/<locale>/<namespace>/<group>.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/<locale>/<group>.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](../compass/README.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](../backpack/README.md), [pact](../pact/README.md), [towel](../towel/README.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.
|
||||
|
||||
@@ -1,3 +1,152 @@
|
||||
# postcard
|
||||
|
||||
`postcard` renders and delivers mail through configured memory, log, SMTP, and template drivers. `party` and Fonoteka's user plugin import it for account mail; configure delivery with `postcard.NewSMTPDriver` in `drivers.go`.
|
||||
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 `<plugin>::mail.<name>` and loaded from `views/mail/<name>.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](../compass/README.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: <secret>
|
||||
tls: mandatory
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
- SummerCMS modules: [backpack](../backpack/README.md), [compass](../compass/README.md), [pact](../pact/README.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.
|
||||
|
||||
@@ -1,3 +1,175 @@
|
||||
# surf
|
||||
|
||||
`surf` builds the HTTP router and middleware stack, including route constraints, recovery, CORS, body limits, rate limits, and server commands. The Summer runtime and Fonoteka application import it to expose plugin routes; assemble the router with `surf.BuildRouter` in `router.go`.
|
||||
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](../cabana/README.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:<bucket>` or `throttle:<max>,<minutes>`, `body.limit:<bytes>`, `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](../wire/README.md)), gets the request locale from the `Accept-Language` header (see [towel](../towel/README.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](../compass/README.md) config:
|
||||
|
||||
| Key | Default | Controls |
|
||||
|-----|---------|----------|
|
||||
| `http.body_limits.default_bytes` | none, required | Request body cap in bytes for every non-raw route; `body.limit:<bytes>` 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](../lagoon/README.md) and the uploads bucket through `attach.OpenBucket`, so their settings must be present as well.
|
||||
|
||||
## CLI commands
|
||||
|
||||
| Command | Flags | Description |
|
||||
|---------|-------|-------------|
|
||||
| `serve` | `--addr` (default `:8080`) | Opens the database and uploads bucket, assembles the router and serves HTTP until SIGINT or SIGTERM, then shuts 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](../backpack/README.md), [bonfire](../bonfire/README.md), [bouncer](../bouncer/README.md), [cabana](../cabana/README.md), [compass](../compass/README.md), [lagoon](../lagoon/README.md) (including `lagoon/attach`), [pact](../pact/README.md), [party](../party/README.md), [towel](../towel/README.md), [wire](../wire/README.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.
|
||||
|
||||
@@ -1,3 +1,125 @@
|
||||
# wristband
|
||||
|
||||
`wristband` implements the MCP OAuth server surface: client registration, authorization, consent, token issuance, and backing-store contracts. Fonoteka's OAuth plugin and API controllers import it; the server entry point is `wristband.Server` in `server.go`.
|
||||
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 `<issuer>/connect?request=<id>`. 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.
|
||||
|
||||
Reference in New Issue
Block a user