Files
summercms/.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-02-SUMMARY.md

10 KiB

phase, plan, subsystem, tags, requires, provides, affects, tech-stack, key-files, key-decisions, patterns-established, requirements-completed, duration, completed
phase plan subsystem tags requires provides affects tech-stack key-files key-decisions patterns-established requirements-completed duration completed
06-http-routing-auth-groups-and-rate-limiting 02 auth
surf
limiter
throttle
clientip
public-share
rate-limit
fixed-window
phase provides
06-http-routing-auth-groups-and-rate-limiting pact.Router verbs and name:param factories, bouncer.Registry, inv_token + inv.scope, GET genres on both auth groups
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)
06-03-route-groups
06-05-tests
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
created modified
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
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
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
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:<principal id> when bouncer.User is set, else Host|ClientIP
HTTP-04
14 min 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