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

@@ -39,7 +39,7 @@ func newServer() *wristband.Server {
> [!WARNING]
> Always set `wristband.Options.Resource` for your deployment. `wristband.DefaultOptions` leaves it empty, and while it is empty every authorization request that sends a `resource` parameter is refused with `invalid_target`.
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:
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. `wristband.Options.ScopesSupported` is both the advertised list and the authorize allow-list: a client may request exactly those scopes (the example above drops `ai`, so a request for it is `invalid_scope`; a host that appends a scope of its own accepts it), and an empty list keeps the default `read`, `write`, `ai` and `offline_access`. The metadata document serves the configured values:
```go src=modules/wristband/example_test.go#ExampleServer_Metadata
srv := newServer()

View File

@@ -281,6 +281,10 @@ for _, rt := range r.Routes() {
// POST /api/blog/posts/{id}/comments acme.blog [throttle:60,1 acme.auth throttle:blog.comments body.limit:65536]
```
## Streaming routes
Every route in a plugin group is buffered: surf holds the handler's headers and body until it returns, so a panic never leaves a half-written response and `Flush` is a no-op inside a route. A response that must reach the client while the handler is still running, such as a server-sent events stream, is registered with `surf.Router.MountStream` instead. A streaming route is a raw route (no house middleware, no default body limit) that is served on the real response writer, so `http.Flusher` and `http.ResponseController` work inside the handler. Named middleware (guards, throttles), a `body.limit`, the request locale, a `cors:<profile>` and the access-log pattern apply exactly as on a raw route. Recovery changes with the first byte: a panic before anything was sent answers the opaque 500 (or the HTML error page for a browser navigation), a panic after it only ends the body, because the status already went out. The framework's MCP module mounts its endpoint this way; `summer route:list` shows the route like any other.
`surf.Router.Handler` hands out the compiled chain of one route by method and pattern, for example `r.Handler("GET", "/api/blog/posts/{id}")`. The chain is the route exactly as the mux serves it: constraints, the named middleware (guards, throttles), the body limit, locale, recovery and the CORS profile. A framework component that calls a route in-process, such as the MCP module, serves that handler with a request it builds itself, setting the path values and `Pattern` as the mux would; the caller's identity reaches the guard through the [dispatch marker](authentication.md#guards-and-middleware), never through a header. The lookup answers false for an unknown route and before `surf.BuildRouter` compiled the routes.
## CORS