fix(19.3-06): cors:<profile> headers on every response of the route (CR-01)

- Router.wrap applies the route's profile outside recovery, body limit and
  locale, whatever its position in the list or the group nesting
- bufferedResponse.commit merges Vary instead of replacing it
- TestCORSProfileHeadersOnRefusals, TestCORSProfileVaryKeptWithHandlerVary
  required by name in the gate
This commit is contained in:
Jakub Zych
2026-10-15 22:24:08 +02:00
parent e9ad640999
commit 072353663e
7 changed files with 226 additions and 11 deletions

View File

@@ -37,12 +37,13 @@ surf wraps every non-raw route in the same layers. From the outside in:
1. Optional locale-prefix rewrite, when a `surf.LocaleResolver` is published: an enabled locale as the first path segment is stripped so later layers and routing see the unprefixed path.
2. CORS, only for the paths configured under `http.cors.paths`, including preflight requests.
3. Panic recovery. A panic becomes an opaque JSON 500 from [wire](../../modules/wire/README.md), and because the response is buffered until the handler returns, the client never receives half a body.
4. The request locale, taken from the published `surf.LocaleResolver` when one exists, or from the `Accept-Language` header otherwise, and stored in the context.
5. The body limit: `http.body_limits.default_bytes`, or the route's own `body.limit:<bytes>`.
6. The route's middleware, in the order you listed them: group middleware first, then the route's own.
7. The path constraints. A request whose parameter fails `pact.Router.Where` or `pact.Router.WhereIn` gets a 404 before your handler runs.
8. Your handler.
3. The route's CORS profile, when it or its group names `cors:<profile>`. It sits here whatever its position in the middleware list, so refusals and recovered panics carry its headers too.
4. Panic recovery. A panic becomes an opaque JSON 500 from [wire](../../modules/wire/README.md), and because the response is buffered until the handler returns, the client never receives half a body.
5. The request locale, taken from the published `surf.LocaleResolver` when one exists, or from the `Accept-Language` header otherwise, and stored in the context.
6. The body limit: `http.body_limits.default_bytes`, or the route's own `body.limit:<bytes>`.
7. The route's middleware, in the order you listed them: group middleware first, then the route's own (`cors:<profile>` excepted, see step 3).
8. The path constraints. A request whose parameter fails `pact.Router.Where` or `pact.Router.WhereIn` gets a 404 before your handler runs.
9. Your handler.
Raw groups, declared with `pact.Router.GroupRaw`, are for webhooks and file streams. They skip the default body limit, refuse house middleware, and a panic in them returns a bare 500.

View File

@@ -98,7 +98,7 @@ The framework registers these names:
|------|------|
| `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. |
| `cors:<profile>` | CORS headers from `http.cors_profiles.<profile>`; see [CORS profiles](#cors-profiles). |
| `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](#cors-profiles). |
| `turnstile:open`, `turnstile:closed` | Cloudflare Turnstile verification; see [Turnstile](authentication.md#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. |
@@ -262,7 +262,7 @@ cors_profiles:
supports_credentials: true
```
This fragment belongs in `config/http.yaml`. A route group then lists `cors:spa` among its middleware names, next to its guard, exactly as it lists `throttle:60,1`.
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.