feat(19.7-01): streaming routes, MCP mount hook, configurable authorize scopes

- surf.Router.MountStream and recoverStream: a raw, unbuffered route kind
  whose http.Flusher reaches the client; panic after the first byte only
  aborts the body
- surf.MCPBuildTag and the mountPier hook: mcp.enabled is inert without the
  summercms_mcp tag until it is true, then boot fails naming the tag
  (pier_off.go is the untagged side)
- wristband parseAuthorizeScopes takes the server's allowed set built from
  Options.ScopesSupported; the default set is unchanged
- cooler.KeyPrefix exported for the MCP module's Redis keys
- smoke tests TestStreamingRouteSmoke, TestPierMountSmoke, TestMCPScopesSmoke
- surf, wristband and cooler READMEs, docs/services/{routing,oauth-server}.md
This commit is contained in:
Jakub Zych
2026-10-22 16:11:37 +02:00
parent 1bd91a4522
commit 5c15dea569
13 changed files with 506 additions and 12 deletions

View File

@@ -34,7 +34,7 @@ Use cases:
- Start-up never fails because Redis is down: an unanswered ping is one warning that names the endpoint (host and port, never the URL), the limiter counts per instance and `cooler.Cache.Remember` degrades until Redis answers. A missing or malformed `redis.url`, an unknown driver name or a `cache.memory.max_entries` below 1 fails start-up with an error that names the key and never contains the URL or its password.
- `cooler.RateLimitStore` runs one atomic Lua script per attempt with the semantics of `surf.MemoryStore`: the first attempt opens the window, a denied attempt does not increment, the window never extends, and the retry time is what is left of the window. Redis keeps the clock, so instance clock skew does not matter.
- Each attempt is bounded by `cooler.AttemptTimeout` (500 ms). When Redis fails, the attempt is counted in the fallback store surf passes in (a per-instance `surf.MemoryStore`) and a warning is logged at most once per minute.
- Keys carry a per-application prefix: `cache.prefix`, or by default `app.name` lower-cased with every run of other characters than `a-z` and `0-9` replaced by `_`, then `:` (`summer:` when `app.name` is empty). Cache keys are `<prefix>cache:<key>`, rate-limit keys `<prefix>rl:<key>`.
- Keys carry a per-application prefix: `cache.prefix`, or by default `app.name` lower-cased with every run of other characters than `a-z` and `0-9` replaced by `_`, then `:` (`summer:` when `app.name` is empty). Cache keys are `<prefix>cache:<key>`, rate-limit keys `<prefix>rl:<key>`; `cooler.KeyPrefix` exposes the prefix to other framework modules that share the Redis.
- `cooler.OpenRedis` accepts `redis://`, `rediss://` (TLS) and `unix://` URLs, does not dial at construction, and skips the `CLIENT SETINFO` and maintenance-notification handshakes.
- Only `serve` installs cooler: `route:list` and `surf.Assemble` in tests keep the in-process store and never dial Redis.
- Site-cache purge: `cooler.SiteCachePurger` sends `POST {"kind":…,"slug":…}` with `Authorization: Bearer <token>` to `site_cache.purge_url`. `cooler.PurgeSiteCacheAfterCommit` queues that request on the save transaction through `lagoon.AfterCommit`, so it is queued only after the save commits and never for a save that rolled back. The save never waits for the site: a per-purger worker sends the request, with at most one in flight, and saves committed meanwhile collapse into one follow-up request (each purge clears the site's whole route cache), so a bulk delete sends at most two. The purger's `Wait` method drains the queue for tests and commands (see the API reference), and `cooler.Handle.Close` drains it at shutdown.
@@ -174,6 +174,7 @@ func AllowExport(ctx context.Context, app *backpack.App, fallback cooler.Attempt
| `cooler.NewRateLimitStore` | Builds a `cooler.RateLimitStore` from a go-redis client, a key prefix and a fallback. |
| `cooler.RateLimitStore.Attempt` | Admits and counts one request in one Lua script; falls back on a Redis error. |
| `cooler.OpenRedis` | Builds a go-redis client from a `redis://`, `rediss://` or `unix://` URL without dialing; errors never contain the URL. |
| `cooler.KeyPrefix` | The application's key prefix (`<prefix>`): `cache.prefix` when set, else `app.name` lower-cased with every run of other characters replaced by `_` plus `:`, else `summer:`. Another framework module that keeps keys in the same Redis puts them under it (the MCP module uses `<prefix>mcp:...`). |
| `cooler.SiteCachePurger` | Sends the site-cache purge request; a nil purger does nothing. |
| `cooler.NewSiteCachePurger` | Builds the purger from the values of `site_cache.purge_url` and `site_cache.purge_token`; both empty returns nil; errors name those keys and never the token. |
| `cooler.NewSiteCachePurgerFor` | The same with the config keys its errors name passed in, for a plugin that reads deprecated keys of its own. |

View File

@@ -355,6 +355,14 @@ func driverName(cfg *compass.Config, key string) (string, error) {
// outside [a-z0-9] replaced by "_" (trimmed at both ends) and a trailing ":",
// or "summer:" when app.name is empty too. Cache keys are <prefix>cache:<key>,
// rate-limit keys <prefix>rl:<key>.
// KeyPrefix returns the key prefix every cooler key of this application
// starts with (see keyPrefix: cache.prefix, else app.name as a slug plus
// ":", else "summer:"), so another framework module keeps its Redis keys
// next to the cache and rate-limit ones (for example <prefix>mcp:...).
func KeyPrefix(cfg *compass.Config) string {
return keyPrefix(cfg)
}
func keyPrefix(cfg *compass.Config) string {
if cfg == nil {
return defaultPrefix