docs(19.3-02): complete cookie transport, credentialed CORS, Turnstile and api-docs plan

This commit is contained in:
Jakub Zych
2026-10-15 18:01:54 +02:00
parent 20bc1a0795
commit e3ba89a411

View File

@@ -0,0 +1,251 @@
---
phase: 19.3-framework-transport-and-infrastructure-for-wavepath
plan: 02
subsystem: auth
tags: [bouncer, surf, cookies, cors, turnstile, openapi, swagger-ui, sri]
requires:
- phase: 19.3-01
provides: "surf BuildRouter store lookup and cooler wiring (this plan extends BuildRouter next to it)"
provides:
- "bouncer cookie transport: CookieConfig/LoadCookieConfig (auth.cookie.*), TokenCookie/ForgetTokenCookie, CookieRefreshHandler/CookieLogoutHandler"
- "surf http.cors hardening: credentials+* and bad/PHP-delimited patterns fail boot, wildcard origins as anchored patterns, exact echo, Vary: Origin on every dynamic answer"
- "surf named CORS profiles: http.cors_profiles.<name>, cors:<profile> middleware, router-registered OPTIONS preflight routes"
- "bouncer Turnstile verifier and middleware; surf turnstile:open|closed factory from turnstile.secret/turnstile.field"
- "surf opt-in /api-docs Swagger UI page and /api-docs.json from one APIDocsProvider plugin (swagger-ui-dist 5.33.1 with SRI)"
affects: [19.3-05, 19.4, 19.5, 19.6]
actuals:
tokens: 26655
tasks: 4
commits: 4
plan_head_before: 866e4fd3e0013d4ddcf41a823fca43d68ac7734f
plan_head_after: 20bc1a0795eef25af5221871c2a41ee75d48744d
tech-stack:
added: []
patterns:
- "Structural config interface (bouncer.CookieConfigSource) so bouncer reads *compass.Config without importing it"
- "Shared corsPolicy used by the global path-scoped wrapper and the cors:<profile> middleware"
- "Router-registered OPTIONS routes (wrapped only in recovery and recordPattern) for route-level CORS"
- "Optional plugin interface type-asserted in BuildRouter (APIDocsProvider), like BucketProvider"
- "Published-service override: a *bouncer.Turnstile or bouncer.TurnstileWriter on the app replaces the defaults"
key-files:
created:
- modules/bouncer/cookie.go
- modules/bouncer/turnstile.go
- modules/surf/apidocs.go
- modules/surf/transport_smoke_test.go
modified:
- modules/surf/cors.go
- modules/surf/router.go
- modules/surf/example_test.go
- modules/bouncer/README.md
- modules/surf/README.md
- docs/services/authentication.md
- docs/services/routing.md
key-decisions:
- "Cookie builders are methods on bouncer.CookieConfig (one config for every guard); the refresh window is passed per call, so Max-Age is the guard's refresh_ttl"
- "LoadCookieConfig takes bouncer.CookieConfigSource (Lookup, String, Environment), which *compass.Config satisfies; bouncer still imports no framework module"
- "Cookie handlers return (http.Handler, error) so missing options fail boot; refresh reads only the cookie and leaves it alone on 401; logout always clears the cookie and answers 200, passing a revoke error to the writer"
- "A CORS answer is dynamic with credentials, any pattern (wildcard origins included) or more than one origin; dynamic answers always carry Vary: Origin; the non-credentialed * answer is byte-identical to before"
- "CORS profiles live under http.cors_profiles.<name> (names [a-z0-9_-]+); the router registers OPTIONS routes owned by surf; overlap with http.cors.paths fails boot"
- "Turnstile divergences from PHP (outcome-preserving): open mode with a secret treats a transport error or timeout as 422 (PHP surfaces a 500); a missing token is 422 without a siteverify call; tokens over 2048 characters fail without a call"
- "Default Turnstile bodies are neutral JSON ({\"error\":\"turnstile_unavailable\"} 503, {\"error\":\"turnstile_failed\"} 422); a published bouncer.TurnstileWriter keeps an app's envelope; a published *bouncer.Turnstile replaces the config-built verifier"
- "api-docs spec converted at boot (a spec that does not convert fails boot naming the plugin, where PHP answers 500 per request); the page uses a per-request CSP nonce and validatorUrl null"
patterns-established:
- "Boot validation of route-level middleware params in Router.wrap (cors:<profile>, turnstile:open|closed) next to throttle and body.limit"
- "Docs Go blocks reference tested example code (ExampleAPIDocsProvider, BlogPlugin.APIDocsSpec)"
requirements-completed: [SC-1, SC-3, SC-4, D-01, D-02, D-03, D-04, D-05, D-16, D-17, D-18, D-19]
coverage:
- id: D1
description: "A www page refreshes its session on the api host: credentialed CORS echoes the exact origin with Vary: Origin, the refresh handler sets token Domain=example.com Max-Age=1209600 HttpOnly Secure SameSite=Lax and blacklists the old jti; the preflight answers 204 with Vary: Origin"
requirement: SC-1
verification:
- kind: integration
ref: "modules/surf/transport_smoke_test.go#TestCookieRefreshAcrossSubdomainsSmoke"
status: pass
human_judgment: false
- id: D2
description: "Credentialed CORS with a * origin fails boot naming http.cors; the Laravel preflight bytes for the non-credentialed * config are unchanged"
requirement: D-04
verification:
- kind: unit
ref: "modules/surf/transport_smoke_test.go#TestCORSCredentialsWildcardRefusedSmoke"
status: pass
- kind: unit
ref: "modules/surf/cors_coverage_test.go#TestCORSOptionsMatchesLaravel"
status: pass
human_judgment: false
- id: D3
description: "cors:<profile> gives a route group its own credentialed policy; the router-registered OPTIONS route answers the preflight (204, exact origin, credentials, Vary: Origin); a disallowed origin gets Vary but no Allow-Origin; cors:missing fails boot"
requirement: D-05
verification:
- kind: integration
ref: "modules/surf/transport_smoke_test.go#TestCORSProfileSmoke"
status: pass
human_judgment: false
- id: D4
description: "turnstile:closed passes a good token with the full body restored, answers 422 for a rejected token and 503 unconfigured in production; turnstile:open passes without a secret and without a siteverify call; turnstile:maybe fails boot"
requirement: D-18
verification:
- kind: integration
ref: "modules/surf/transport_smoke_test.go#TestTurnstileMiddlewareSmoke"
status: pass
human_judgment: false
- id: D5
description: "/api-docs is 404 by default; enabled, /api-docs.json serves compact JSON in source key order and /api-docs carries swagger-ui-dist@5.33.1, three sha384 integrity attributes and a nonce matching the CSP; two providers fail boot"
requirement: D-17
verification:
- kind: integration
ref: "modules/surf/transport_smoke_test.go#TestAPIDocsSmoke"
status: pass
- kind: unit
ref: "modules/surf/example_test.go#ExampleAPIDocsProvider"
status: pass
human_judgment: false
- id: D6
description: "The Swagger UI page renders and loads the spec in a real browser under its Content-Security-Policy and SRI"
requirement: D-17
verification: []
human_judgment: true
rationale: "No browser test runs here; the bundle contains Function(\"return this\") fallbacks, so whether the CSP without unsafe-eval lets Swagger UI start needs a browser check"
- id: D7
description: "A browser on a real www subdomain keeps its session through the api host with the shared Domain cookie"
requirement: SC-1
verification: []
human_judgment: true
rationale: "The smoke test drives the handler with httptest; browser cookie-jar and SameSite behaviour across real subdomains needs a manual check"
- id: D8
description: "READMEs and docs updated in the same commits; docs checker and docs:build --check green"
requirement: SC-4
verification:
- kind: other
ref: "go test -run '^TestDocsTree$' ./cmd/summer && go run ./cmd/summer docs:build --check"
status: pass
human_judgment: false
duration: 15min
completed: 2026-10-15
status: complete
---
# Phase 19.3 Plan 02: Cookie transport, credentialed CORS, Turnstile and API docs Summary
**httpOnly token-cookie refresh across subdomains in bouncer, boot-validated credentialed CORS with per-group cors:<profile> policies and router-registered preflights, Cloudflare Turnstile as turnstile:open|closed middleware, and an opt-in /api-docs Swagger UI page pinned to swagger-ui-dist 5.33.1 with SRI**
## Performance
- **Duration:** 15 min
- **Started:** 2026-10-15T15:45:56Z
- **Completed:** 2026-10-15T16:00:55Z
- **Tasks:** 4
- **Files modified:** 11
## Accomplishments
- Cookie transport (D-01..D-03): `bouncer.LoadCookieConfig` reads `auth.cookie.{name,domain,path,secure,same_site}` with PHP defaults (token, `/`, HttpOnly, Lax, host-only, Secure when `app.url` is https) and refuses an invalid domain, `none` without Secure and `secure: false` in production. `bouncer.CookieConfig.TokenCookie` sets Max-Age to the refresh window. `bouncer.CookieRefreshHandler` and `bouncer.CookieLogoutHandler` wrap the app's refresh or revoke func and writer.
- CORS hardening (D-04): `surf.LoadCORSConfig` refuses credentials with `*`, uncompilable patterns and PHP-delimited patterns, naming the key. Wildcard origins become anchored patterns. Dynamic answers echo the exact origin and always send `Vary: Origin`. `TestCORSOptionsMatchesLaravel` is still green.
- CORS profiles (D-05): `surf.LoadCORSProfiles` reads `http.cors_profiles.<name>` and the `cors:<profile>` factory applies them. The router registers an `OPTIONS` route (owner `surf`) per profiled path. Boot errors cover an unknown profile, conflicting profiles, an existing OPTIONS route and overlap with `http.cors.paths`.
- Turnstile (D-18): `bouncer.Turnstile.Verify` follows PHP's open and closed modes, with a 5 s bound, a 4 KiB answer cap and the 2048-character token limit. `bouncer.Turnstile.Middleware` reads the token from the query, a JSON body or a form body and restores the body. surf registers `turnstile:open|closed` from `turnstile.secret` (`SUMMER_TURNSTILE__SECRET`) and `turnstile.field`, using the trusted-proxy client IP.
- API docs (D-16, D-17): `surf.APIDocsProvider` and `http.api_docs.{enabled,path,title}` mount public `GET /api-docs` and `/api-docs.json`. The spec is converted once at boot. The page pins swagger-ui-dist `5.33.1`, carries sha384 SRI on all three assets (re-verified against jsDelivr during execution) and uses a nonce CSP.
## Task Commits
1. **Task 1 (tracer): cookie refresh across subdomains + CORS hardening** - `e7659f2` (feat)
2. **Task 2: cors:<profile> with router-registered preflights** - `5055e8b` (feat)
3. **Task 3: Turnstile verifier and turnstile:open|closed middleware** - `597c79b` (feat)
4. **Task 4: opt-in /api-docs page with pinned SRI assets** - `20bc1a0` (feat)
## Files Created/Modified
- `modules/bouncer/cookie.go` - CookieConfig, LoadCookieConfig, TokenCookie/ForgetTokenCookie, cookie refresh/logout handlers
- `modules/bouncer/turnstile.go` - Turnstile verifier, verdicts, middleware, TurnstileWriter
- `modules/surf/cors.go` - validation, corsPolicy, wildcard origins, Vary, LoadCORSProfiles, preflight route registration
- `modules/surf/router.go` - `cors` and `turnstile` factories, param validation in wrap, preflight routes, mountAPIDocs call, buildTurnstile
- `modules/surf/apidocs.go` - APIDocsProvider, SwaggerUIVersion, pinned assets, page and spec routes
- `modules/surf/transport_smoke_test.go` - the five smoke tests
- `modules/surf/example_test.go` - BlogPlugin.APIDocsSpec, ExampleAPIDocsProvider (docs snippet source)
- `modules/bouncer/README.md`, `modules/surf/README.md`, `docs/services/authentication.md`, `docs/services/routing.md` - Cookie transport, Turnstile, CORS rules, CORS profiles, API documentation page
## Config keys and identifiers added
- Keys: `auth.cookie.name`, `auth.cookie.domain`, `auth.cookie.path`, `auth.cookie.secure`, `auth.cookie.same_site`; `http.cors_profiles.<name>.*`; `turnstile.secret`, `turnstile.field`; `http.api_docs.enabled`, `http.api_docs.path`, `http.api_docs.title`.
- Middleware: `cors:<profile>`, `turnstile:open`, `turnstile:closed`. Routes: `GET /api-docs`, `GET /api-docs.json` (opt-in), plus `OPTIONS <path>` per profiled path.
- bouncer: `CookieConfig`, `CookieConfigSource`, `LoadCookieConfig`, `DefaultCookieName`, `CookieResult`, `CookieRefreshOptions`, `CookieLogoutOptions`, `CookieRefreshHandler`, `CookieLogoutHandler`, `ErrNoTokenCookie`, `Turnstile`, `NewTurnstile`, `TurnstileSiteverifyURL`, `TurnstileTimeout`, `TurnstileMaxTokenLength`, `DefaultTurnstileField`, `TurnstileVerdict` (`TurnstilePassed`, `TurnstileFailed`, `TurnstileUnavailable`), `TurnstileWriter`.
- surf: `LoadCORSProfiles`, `APIDocsProvider`, `SwaggerUIVersion`.
## Decisions Made
See `key-decisions` in the frontmatter. The divergences from PHP are deliberate:
- Credentials with `*` is a boot error (php-cors reflects any origin).
- In Turnstile open mode with a secret, a transport error or timeout is a 422 (PHP surfaces a 500).
- A missing Turnstile token, or one over 2048 characters, is a 422 without a siteverify call.
- A spec that does not convert fails boot (PHP answers 500 per request).
- The Swagger UI version is exact and SRI-checked (PHP loads the floating `@5` without integrity).
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 3 - Blocking] A published `*bouncer.Turnstile` overrides the config-built verifier**
- **Found during:** Task 3
- **Issue:** The smoke test must point the verifier's `Endpoint` at an httptest fake, but BuildRouter builds the verifier from config, so the test had no way to reach it.
- **Fix:** `buildTurnstile` uses a `*bouncer.Turnstile` published on the app when there is one. Otherwise it builds from `turnstile.secret`. This is documented in the surf README and the Turnstile section.
- **Files modified:** modules/surf/router.go, docs
- **Committed in:** 597c79b
**2. [Rule 2 - Missing critical] Turnstile middleware handles a body read error**
- **Found during:** Task 3
- **Issue:** The plan had the middleware read the whole body but said nothing about a read error, such as the router's body cap tripping.
- **Fix:** An `*http.MaxBytesError` answers 413 and any other read error answers 400, both through the writer with `TurnstileFailed`. The handler is never called with a broken body.
- **Committed in:** 597c79b
**3. [Rule 3 - Blocking] Docs checker requires Go blocks to come from tested code**
- **Found during:** Task 2 and Task 4
- **Issue:** A Go code block in docs/ must carry a `src=` reference to a function a Test or Example runs.
- **Fix:** The CORS profile group example became prose. For the API docs page, `BlogPlugin.APIDocsSpec` and `ExampleAPIDocsProvider` (with `// Output:`) were added to `modules/surf/example_test.go`, which is not in the plan's file list. `TestDocsDeclarations` calls the method.
- **Committed in:** 20bc1a0
**4. [Rule 2] Small hardening and accuracy additions**
- Zero-value `CookieConfig` fields default to Name `token` and Path `/` in the builders and handlers.
- Turnstile `success:false` with Cloudflare error codes (sanitized) is logged at debug; transport failures are logged at warn.
- `validatorUrl: null` in the Swagger config, so the spec URL is never sent to swagger.io.
- docs/services/routing.md's middleware table gained the turnstile row in Task 3 (routing.md was not in Task 3's file list).
- The surf README's standard-library list gained `log/slog` and `net/url`, which were missing before this plan.
- **Committed in:** e7659f2, 597c79b, 20bc1a0
---
**Total deviations:** 4 (2 Rule 3, 2 Rule 2).
**Impact on plan:** None of these change behaviour the plan specified. The two seams it adds (a published verifier, the example test) are small and documented.
## Issues Encountered
None.
## User Setup Required
None. Apps opt in through config (`auth.cookie.*`, `http.cors_profiles`, `SUMMER_TURNSTILE__SECRET`, `http.api_docs.enabled`).
## Next Phase Readiness
- Plan 05 (unit tests) has full coverage to write for cookie.go, turnstile.go, cors.go (profiles, wildcard origins, PHP-delimited patterns) and apidocs.go. The smoke tests here are the starting point.
- Human UAT: check that the Swagger UI page renders in a browser under its CSP (D6), and that the cookie survives across real subdomains (D7).
## Self-Check: PASSED
- FOUND: modules/bouncer/cookie.go, modules/bouncer/turnstile.go, modules/surf/apidocs.go, modules/surf/transport_smoke_test.go
- FOUND commits: e7659f2, 5055e8b, 597c79b, 20bc1a0
- `go vet ./...` and `go test -short ./...` green; five smoke tests plus TestCORSOptionsMatchesLaravel pass; TestDocsTree and `docs:build --check` green
- bouncer imports no SummerCMS module; no "wavepath" in modules/bouncer, modules/surf or docs/services
---
*Phase: 19.3-framework-transport-and-infrastructure-for-wavepath*
*Completed: 2026-10-15*