diff --git a/.planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-02-SUMMARY.md b/.planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-02-SUMMARY.md new file mode 100644 index 0000000..552f587 --- /dev/null +++ b/.planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-02-SUMMARY.md @@ -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., cors: 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: 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. (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:, 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: 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: 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.` and the `cors:` 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: 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..*`; `turnstile.secret`, `turnstile.field`; `http.api_docs.enabled`, `http.api_docs.path`, `http.api_docs.title`. +- Middleware: `cors:`, `turnstile:open`, `turnstile:closed`. Routes: `GET /api-docs`, `GET /api-docs.json` (opt-in), plus `OPTIONS ` 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*