Files
summercms/docs/services/routing.md
Jakub Zych e32940d90f fix(19.3-06): credentialed CORS refuses wildcard-host and unanchored origins (WR-04)
- with supports_credentials, a * origin must be the whole leftmost host
  label, patterns must be anchored with ^ and $, and no origin rule may
  match the probe origins outside any configured domain
- globally and per profile; non-credentialed configs load as before
2026-10-15 22:25:43 +02:00

19 KiB

title, description, section, order
title description section order
Routing Declare a plugin's HTTP routes with groups, auth groups, path constraints and named middleware through pact.HasRoutes, and write JSON responses with wire. services 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 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:

// 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:

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})
}

Overlapping constrained routes

Laravel matches routes in the order routes.php declares them and checks ->where() constraints while it matches. Two routes can therefore share a shape that only their constraints keep apart, for example GET /shelves/items/{id} with a numeric id and GET /shelves/{shelfId}/follow with a numeric shelfId. Both patterns match /shelves/items/follow, and neither is more specific than the other, so the standard library ServeMux refuses to register them together.

surf accepts such routes. When the application starts, it groups every set of routes that ServeMux would refuse side by side into an overlap family and registers the family once, under a generated pattern with a wildcard wherever its routes differ. A request that reaches the family is tried against its routes in registration order. The first route whose literal segments and constraints match runs, with its own path values, middleware, body limit and panic recovery. Declare overlapping routes in the order the PHP file declares them.

  • No match: a request whose path no route of the family matches gets the same bare 404 as any unknown path, for every method.
  • Wrong method: a request whose path a route matches, but not with its method, gets the 405 and Allow header that ServeMux gives for the same routes without the overlap.
  • More specific routes stay outside the family: a literal GET /shelves/items/similar keeps answering its own path.
  • Route table: surf.Router.Routes and route:list list every route of a family on its own, with its own pattern and middleware.

A family supports literal segments and single-segment {name} wildcards only. These shapes still stop the start-up with a route conflict: two routes of one method that differ only in parameter names, a {name...} or {$} wildcard or a trailing slash in a family, and a more general route of the same method, such as GET /shelves/{a}/{b}, that would take a family member's requests.

Auth groups

An auth group is a group whose middleware list names a guard. The plugin above turns a bouncer JWT guard into named middleware and returns it from pact.HasMiddleware:

// 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 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.
body.limit:<bytes> Replaces the default request body limit for the route.
cors:<profile> CORS headers from http.cors_profiles.<profile> on every response of the route, refusals and recovered panics included, whatever its position in the list; see CORS profiles.
turnstile:open, turnstile:closed Cloudflare Turnstile verification; see Turnstile.
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 a published surf.LocaleResolver or from Accept-Language when none is published, 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.

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 []:

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:

// 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:

./bin/acme route:list

The table is followed by a line saying whether the admin is mounted and under which prefix, for example admin: mounted at /backend.

surf.BuildRouter returns the same information to Go code through surf.Router.Routes:

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:

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.

The rules surf applies to the origin answer:

  • supports_credentials: true together with * in allowed_origins fails the start-up. A credentialed answer that trusted any origin would let every site read responses made with the user's cookies; Laravel's php-cors reflects the origin in that case, surf refuses it.

  • With supports_credentials: true, three more rules close other spellings of the same exposure, and each refusal names the key with its index, such as http.cors.allowed_origins[1]:

    • an allowed_origins entry with a * is accepted only when the scheme is literal and the * is the whole leftmost host label, as in https://*.example.com; https://*, *://*, https://*example.com and http*://www.example.com fail the start-up;
    • every allowed_origins_patterns entry must be anchored with ^ and $, as in ^https://[a-z]+\.example\.com$; an unanchored pattern matches inside any origin, so https://.*\.example\.com would trust https://x.example.com.attacker.net;
    • no origin rule may match an origin outside the configured domains: surf tries each rule against probe origins such as https://cors-probe.invalid and null, which refuses https://*.com and ^https?://.*$.

    Without credentials these entries load as before.

  • An allowed origin is echoed exactly in Access-Control-Allow-Origin; * is sent only for allowed_origins: ["*"] without credentials, and that answer is unchanged from earlier releases.

  • When the answer depends on the request's Origin (with supports_credentials, with any pattern, or with more than one listed origin), every response on a CORS path carries Vary: Origin, the preflight and a disallowed origin's response included, so a shared cache never serves one origin's answer to another.

  • An allowed_origins entry with a * inside, such as https://*.example.com, matches like Laravel's Str::is: the * stands for any characters and the rest is literal.

  • allowed_origins_patterns entries are Go regular expressions. One that does not compile fails the start-up, and so does one still wrapped in PHP delimiters, such as #^https://.*\.example\.com$#: drop the delimiters and any flags when porting it.

A frontend on https://www.example.com that calls an API on api.example.com with the session cookie (see Cookie transport) needs the www origin listed and credentials on:

cors:
  paths: ["api/*"]
  allowed_origins: ["https://www.example.com"]
  allowed_methods: ["*"]
  allowed_headers: ["*"]
  supports_credentials: true

The browser sends the request with credentials: "include"; the answer carries Access-Control-Allow-Origin: https://www.example.com, Access-Control-Allow-Credentials: true and Vary: Origin.

surf answers every OPTIONS request on a CORS path itself, with 204, before any route runs, so a plugin never registers an OPTIONS route. A preflight gets the same headers Laravel's HandleCors sends: with allowed_methods: ["*"] the Access-Control-Allow-Methods value is the requested method, upper-cased, and with allowed_headers: ["*"] the Access-Control-Allow-Headers value is the requested header list.

CORS profiles

http.cors applies one policy to a set of paths. A route group that needs its own policy, for example a credentialed one for a single-page app while the rest of the API stays public, names a profile with the cors:<profile> middleware. Profiles live under http.cors_profiles.<name> and take the http.cors keys except paths:

cors_profiles:
  spa:
    allowed_origins: ["https://www.example.com"]
    allowed_methods: ["GET", "POST"]
    allowed_headers: ["Content-Type"]
    supports_credentials: true

This fragment belongs in config/http.yaml. The route, or its group, then lists cors:spa among its middleware names, as it lists throttle:60,1. surf applies the profile outside every other layer of the route, whatever its position in the list or the group nesting. Every response of the route therefore carries the profile's headers, including refusals from guards (401, 403), throttle (429) and Turnstile (422, 503), the body limit (413) and recovered panics (500); otherwise the browser would hide them from the page, and a single-page app could not see the 401 that starts its cookie refresh.

A profile is checked like http.cors: credentials with a * origin, a pattern that does not compile and a PHP-delimited pattern fail the start-up, with errors naming http.cors_profiles.<name>.<key>. Profile names use lower-case letters, digits, underscore and dash, and a profile that sets paths fails too.

ServeMux answers an OPTIONS request on a path registered only for GET or POST with 405 before any route middleware runs, so the middleware alone would never see a browser's preflight. surf therefore registers one OPTIONS <path> route for every path whose routes name cors:<profile>, owned by surf in route:list. It answers the preflight from the profile, with the same headers as the global preflight, and runs no other middleware: a preflight carries no credentials, so no auth, throttle or body limit applies. The start-up fails when a route names an unknown profile, when two routes on one path name different profiles, when a plugin already declared an OPTIONS route on that path, and when the path also matches http.cors.paths, because the global wrapper answers every preflight on its paths before routing.

API documentation page

An application can publish a Swagger UI page for its public API. The page is off by default; with http.api_docs.enabled: true surf mounts two public routes, without any middleware:

  • GET /api-docs: the Swagger UI page.
  • GET /api-docs.json: the OpenAPI document as compact JSON.

The document comes from the one plugin that implements surf.APIDocsProvider. It returns the document as YAML or JSON, usually a file it embeds:

// APIDocsSpec supplies the document behind the opt-in /api-docs page
// (surf.APIDocsProvider).
func (p *BlogPlugin) APIDocsSpec() ([]byte, error) {
	return []byte(openAPISpec), nil
}

surf converts the document once at start-up: a document starting with { must be valid JSON, anything else is read as YAML, and the result keeps the source's key order. A document that does not convert fails the start-up with the plugin's name, and so do zero or several providers while the page is enabled. Nothing is generated from the code: the document describes exactly the surface you choose to publish.

Key Default Controls
http.api_docs.enabled false Mounts the page and the JSON route.
http.api_docs.path /api-docs The page path; the JSON route is the same path plus .json. It must be an absolute URL path, and neither route may replace a plugin route.
http.api_docs.title API documentation The page title.

The page loads swagger-ui-dist from jsDelivr at the exact version surf.SwaggerUIVersion, and every script and stylesheet carries a Subresource Integrity hash with crossorigin="anonymous", so the browser refuses a file that changed on the CDN. A floating version such as @5 cannot carry a hash, which is why the version is pinned and moves only with a framework release. The page sends a Content-Security-Policy that allows scripts only from jsDelivr and its own inline script (through a per-request nonce), plus X-Content-Type-Options: nosniff and Referrer-Policy: no-referrer.