--- phase: 06-http-routing-auth-groups-and-rate-limiting plan: 02 subsystem: auth tags: [surf, limiter, throttle, clientip, public-share, rate-limit, fixed-window] requires: - phase: 06-http-routing-auth-groups-and-rate-limiting provides: pact.Router verbs and name:param factories, bouncer.Registry, inv_token + inv.scope, GET genres on both auth groups provides: - surf.FixedWindowLimiter, Store/MemoryStore, Bucket, BucketProvider, and the built-in throttle middleware factory - surf.ClientIP / TrustedProxies trusted-proxy resolver (D-04) - five fonoteka-* buckets registered by golem15.fonoteka with PHP names, limits, and keys - PublicShareHeaders 429 rewrite and X-Robots-Tag/Cache-Control on the public-share group - empty jwt_locale, onboarding, public_invitation, public_share group builders (D-15) affects: [06-03-route-groups, 06-05-tests] tech-stack: added: [] patterns: - parameterized throttle:name and throttle:N,M via RegisterMiddlewareFactory - BucketProvider type-asserted in surf.Assemble (not a pact interface; avoids surf/pact cycle) - Laravel tooManyAttempts-before-hit fixed window; success responses carry X-RateLimit-Limit/Remaining key-files: created: - surf/limiter.go - surf/limiter_store.go - surf/clientip.go - surf/limiter_test.go - surf/clientip_test.go - plugins/golem15/fonoteka/middleware/public_share_headers.go - plugins/golem15/fonoteka/middleware/public_share_headers_test.go - plugins/golem15/fonoteka/routes_bucket_test.go modified: - surf/router.go - pact/capabilities.go - plugins/golem15/fonoteka/plugin.go - plugins/golem15/fonoteka/routes.go - config/http.yaml - parity/php_parity.sh - .planning/ROADMAP.md - .planning/REQUIREMENTS.md key-decisions: - "Concrete limiter is FixedWindowLimiter; surf.Limiter interface remains the unused Phase 3 seam (D-03, D-05)" - "Trusted-proxy list is a NewFixedWindowLimiter constructor argument, never a setter" - "fonoteka-* buckets live on the app plugin via surf.BucketProvider, not hardcoded in surf" - "Empty PHP group builders plus test-only boot-probe routes prove middleware strings resolve without 501 shells (D-15)" - "php_parity.sh pins APP_DEBUG=false; three existing HTML exception fixtures need re-recording" patterns-established: - "Assemble registers the surf throttle factory, then HasMiddleware, HasMiddlewareFactories, BucketProvider, then Routes" - "PublicShareHeaders buffers the inner response so 429 bodies can be rewritten after the limiter writes headers" - "Inline throttle:N,M keys by u: when bouncer.User is set, else Host|ClientIP" requirements-completed: [HTTP-04] duration: 14 min completed: 2026-09-19 --- # Phase 6 Plan 02: Fixed-window limiter, five buckets, and PublicShareHeaders Summary **Laravel ThrottleRequests wire parity in surf.FixedWindowLimiter (success + 429 headers, first-hit-wins fixed window), five fonoteka-* buckets registered by the app plugin, PublicShareHeaders 429 rewrite, and APP_DEBUG=false on the PHP recorder** ## Performance - **Duration:** 14 min - **Started:** 2026-09-19T17:29:07Z - **Completed:** 2026-09-19T17:42:58Z - **Tasks:** 3 - **Files modified:** 16 ## Accomplishments - `surf.Store` / `MemoryStore` and `surf.FixedWindowLimiter` port Laravel's tooManyAttempts-before-hit, first-hit-wins window, stacked `throttle:` middleware, and header placement (`X-RateLimit-Limit`/`Remaining` on 200; `Retry-After`/`X-RateLimit-Reset` on 429 with `{"message":"Too Many Attempts."}`) - `surf.ClientIP` honors `X-Forwarded-For` only when `RemoteAddr` is inside `http.trusted_proxies`; empty list is RemoteAddr only - `golem15.fonoteka` implements `surf.BucketProvider` with the five PHP buckets (60/30/30/60/120) and attaches `throttle:fonoteka-api-token` to the personal-token genres group; `PublicShareHeaders` rewrites 429 to `{"error":"Too many requests"}` and sets `X-Robots-Tag` / `Cache-Control` - Remaining Phase-6-scope groups (`jwt_locale`, `onboarding`, `public_invitation`, `public_share`) are declared as empty builders; `TestAllRouteGroupsBoot` wraps their real middleware stacks; `php_parity.sh` exports `APP_DEBUG=false`; ROADMAP/REQUIREMENTS say five buckets ## Task Commits Each task was committed atomically: 1. **Task 1: Fixed-window Store, FixedWindowLimiter, trusted-proxy ClientIP, and router wiring** - `68c6ab8` (feat, summercms.go) 2. **Task 2: Register the five buckets, attach throttle:fonoteka-api-token, port PublicShareHeaders, declare remaining route groups** - `0acb3f6` (feat, fonoteka.go) 3. **Task 3 code: APP_DEBUG=false and boot-smoke coverage for all groups** - `fbf143e` (feat, fonoteka.go) 4. **Task 3 docs: HTTP-04 named bucket count to five** - `ab20ad0` (docs, summercms.go) **Plan metadata:** (this commit) ## Files Created/Modified - `surf/limiter_store.go` — Store interface and mutex-guarded MemoryStore (Hit / TooManyAttempts / AvailableIn) - `surf/limiter.go` — Bucket, BucketProvider, FixedWindowLimiter, named and inline throttle:N,M - `surf/clientip.go` — ClientIP and TrustedProxies - `surf/router.go` — limiter field, throttle factory, BucketProvider loop, noOpLimit removed; Limiter interface kept - `pact/capabilities.go` — comment that BucketProvider is type-asserted in Assemble - `plugins/golem15/fonoteka/plugin.go` — Buckets() and public.share-headers - `plugins/golem15/fonoteka/routes.go` — token-group throttle plus four empty group builders - `plugins/golem15/fonoteka/middleware/public_share_headers.go` — 429 rewrite and noindex/no-store - `config/http.yaml` — `trusted_proxies: []` - `parity/php_parity.sh` — `APP_DEBUG=false` - `.planning/ROADMAP.md` / `.planning/REQUIREMENTS.md` — five named buckets (D-01) ## Decisions Made - Named the concrete type `FixedWindowLimiter` so it coexists with the retained `surf.Limiter` interface; rate limiting is exclusively `throttle:...` middleware (D-03, D-05) - Trusted proxies are captured at `NewFixedWindowLimiter` construction so named-bucket keys and inline N,M share one list - Named `fonoteka-*` buckets are registered by the app plugin via `surf.BucketProvider`; surf stays Płytarium-agnostic - Empty group builders in `routes.go` plus test-only `/boot-probe/...` routes satisfy D-15 (no 501 shells) while still failing Assemble on unknown throttle/middleware names - Production-shaped error bodies: `APP_DEBUG=false` from this point; three already-recorded HTML exception fixtures are flagged for re-record (not 429s) ## Deviations from Plan ### Auto-fixed Issues **1. [Rule 3 - Blocking] -short boot-smoke cannot register inv_token without *gorm.DB** - **Found during:** Task 3 (`TestAllRouteGroupsBoot -short`) - **Issue:** `inv_token` is registered only when Boot sees a published `*gorm.DB`. The plan's verify command uses `-short`, which skips testcontainers. - **Fix:** If the registry has no `inv_token` middleware after Activate, register a stub Guard so wrap() can resolve the personal-token stack. Real TokenGuard remains the production path. - **Files modified:** `plugins/golem15/fonoteka/routes_bucket_test.go` - **Verification:** `go test ./plugins/golem15/fonoteka/... -run TestAllRouteGroupsBoot -short` passes - **Committed in:** `fbf143e` (Task 3) **2. [Rule 2 - Missing Critical] Empty groups never reach wrap(), so throttle names would not fail boot** - **Found during:** Task 3 (boot-smoke design) - **Issue:** `r.Group(..., Use(...), func(g) {})` with zero Get/Post registers no routes; Assemble would succeed even if `public.share-headers` or a bucket name were missing. - **Fix:** `bootProbePlugin` mounts throwaway `GET /boot-probe/.../ok` routes using each group's real middleware list (not PHP paths, not 501 shells). Assemble of user + fonoteka + probe fails on unknown names. - **Files modified:** `plugins/golem15/fonoteka/routes_bucket_test.go` - **Verification:** TestAllRouteGroupsBoot passes; missing-bucket Assemble already covered in surf tests - **Committed in:** `fbf143e` (Task 3) --- **Total deviations:** 2 auto-fixed (1 Rule 2, 1 Rule 3) **Impact on plan:** Both keep the -short boot-smoke honest without adding production 501 routes. No limiter-algorithm change. ## Issues Encountered - `go test ./... -short` in fonoteka.go fails `TestParityCorpus` coverage (`ported 2 passing 0`) because `parityDB` skips under `testing.Short()` while the coverage subtest still requires passing==ported. Pre-existing; not caused by this plan. Plan verification (`./plugins/golem15/fonoteka/... -short` and `./surf/... -short`) is green. Extra `X-RateLimit-*` headers are outside tide's compare allow-list, so they will not fail a full (non-short) replay. ## Fixture audit (T-06-09) `CACHE_DRIVER=array` still means live PHP 429s cannot be recorded from this harness. Three existing non-429 fixtures contain Winter HTML exception pages (`exception-name-block` / stack trace) and were recorded under debug: - `parity/fixtures/routes/POST___fonoteka_api_v1_invitations_{token}_accept_jwt.yaml` - `parity/fixtures/routes/POST___fonoteka_api_v1_tokens_jwt.yaml` - `parity/fixtures/routes/POST___fonoteka_api_v1_oauth_consent_jwt.yaml` Re-record these against the now-pinned `APP_DEBUG=false` isolated PHP when those routes are ported. Do not treat them as 429 contract fixtures. ## User Setup Required None - no external service configuration required. ## Next Phase Readiness Ready for 06-03 (route table, raw OAuth group, path-scoped CORS). Throttle factory, buckets, ClientIP, and public-share 429 shape are load-bearing. oauth group remains unregistered (D-09). ## Self-Check: PASSED - FOUND: surf/limiter.go, surf/limiter_store.go, surf/clientip.go, plugins/golem15/fonoteka/middleware/public_share_headers.go, plugins/golem15/fonoteka/routes_bucket_test.go - FOUND: 68c6ab8, 0acb3f6, fbf143e, ab20ad0 - FOUND: zero `noopLimiter|noOpLimit` in surf/; zero "seven named" in ROADMAP.md/REQUIREMENTS.md; APP_DEBUG=false in php_parity.sh - `go vet ./...` green in both repos; `go test ./surf/... -short` and `go test ./plugins/golem15/fonoteka/... -short` green --- *Phase: 06-http-routing-auth-groups-and-rate-limiting* *Completed: 2026-09-19*