Files
summercms/.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-02-PLAN.md
2026-09-19 17:16:58 +02:00

36 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
06-http-routing-auth-groups-and-rate-limiting 02 execute 2
06-01
summercms.go/surf/limiter.go
summercms.go/surf/limiter_store.go
summercms.go/surf/clientip.go
summercms.go/surf/router.go
summercms.go/surf/limiter_test.go
summercms.go/surf/clientip_test.go
summercms.go/pact/capabilities.go
fonoteka.go/plugins/golem15/fonoteka/plugin.go
fonoteka.go/plugins/golem15/fonoteka/routes.go
fonoteka.go/plugins/golem15/fonoteka/middleware/public_share_headers.go
fonoteka.go/plugins/golem15/fonoteka/middleware/public_share_headers_test.go
fonoteka.go/plugins/golem15/fonoteka/routes_bucket_test.go
fonoteka.go/config/http.yaml
fonoteka.go/parity/php_parity.sh
.planning/ROADMAP.md
.planning/REQUIREMENTS.md
true
HTTP-04
truths artifacts key_links
The five fonoteka buckets exist with the exact names, limits and key composition from routes.php: fonoteka-api-token (60/min, tok:<id> else IP), fonoteka-oauth-token (30/min, oauthtok:<ip>), fonoteka-oauth-register (30/min, oauthreg:<ip>), fonoteka-public-token (60/min, pubtok:<route token>), fonoteka-public-ip (120/min, IP) (D-01)
The limiter is a fixed window matching Laravel's tooManyAttempts-before-hit, first-hit-wins control flow, not a token bucket or sliding window (D-02, Pitfall 1)
X-RateLimit-Limit and X-RateLimit-Remaining are set on every throttled route's successful response, not only on 429s; Retry-After and X-RateLimit-Reset appear only on 429 (D-02, Pitfall 3)
Stacking two throttle: middleware entries on one route (fonoteka-public-token + fonoteka-public-ip) enforces both budgets independently (D-01)
Client IP for limiter keys comes from RemoteAddr unless RemoteAddr is inside http.trusted_proxies, in which case the rightmost untrusted X-Forwarded-For hop is used; an empty trusted-proxies list means RemoteAddr only (D-04)
The public-share group's 429 body is {"error":"Too many requests"} via PublicShareHeaders, never the house-default {"message":"Too Many Attempts."} body used elsewhere (D-02 Pitfall 2)
php_parity.sh exports APP_DEBUG=false so recorded error fixtures reflect the production (non-debug) body shape (user-resolved Open Question 2)
ROADMAP.md and REQUIREMENTS.md HTTP-04 say five named buckets, not seven (D-01 miscount correction)
Rate-limit counters live in-process behind a small Store interface (mutex-guarded map with expiry sweep), stdlib only -- no otter/cooler package this phase (D-03)
The new concrete rate limiter is named FixedWindowLimiter, distinct from the pre-existing surf.Limiter interface seam declared in router.go; no type is redeclared (D-05, resolves the Phase 3 Limiter interface naming collision)
path provides
summercms.go/surf/limiter_store.go Store interface + MemoryStore: Hit, TooManyAttempts, AvailableIn matching Illuminate\Cache\RateLimiter semantics
path provides
summercms.go/surf/limiter.go Bucket type, FixedWindowLimiter (the concrete rate limiter -- distinct from the pre-existing surf.Limiter interface), RegisterBucket, the throttle: middleware factory wired into surf.Router
path provides
summercms.go/surf/clientip.go ClientIP(r, trusted) and TrustedProxies(cfg) -- the single source of client IP for limiter keys
path provides
fonoteka.go/plugins/golem15/fonoteka/middleware/public_share_headers.go PublicShareHeaders: X-Robots-Tag/Cache-Control on every response, 429 body rewrite with header preservation
from to via pattern
fonoteka.go/plugins/golem15/fonoteka/plugin.go summercms.go/surf/limiter.go Plugin implements surf.BucketProvider, registering the five buckets at Boot Buckets() map[string]surf.Bucket
from to via pattern
summercms.go/surf/router.go summercms.go/surf/limiter.go Router registers a built-in "throttle" middleware factory bound to its FixedWindowLimiter RegisterMiddlewareFactory(.*"throttle"
Replace the Phase 3 no-op limiter with a real fixed-window rate limiter that ports Płytarium's rate-limit contract 1:1: the five named `fonoteka-*` buckets, inline `throttle:N,M` limits, stacked limiters on one route, and Laravel's exact wire behavior (headers on success and on 429, first-hit-wins fixed window, no token-bucket smoothing). Wire the trusted-proxy-aware client IP function that both the limiter and D-08's token surface can share, and port `PublicShareHeaders`' 429 rewrite for the anonymous public-share group.

Purpose: HTTP-04 requires byte-for-byte parity with Laravel's ThrottleRequests/RateLimiter, which has documented, non-obvious semantics (fixed window, not token bucket; headers on success too; tooManyAttempts checked BEFORE hit) -- getting this wrong changes observable client-visible behavior, not just an implementation detail. Output: surf.FixedWindowLimiter/surf.Store/surf.Bucket/surf.ClientIP; the "throttle" middleware factory; the five fonoteka buckets registered and attached to the token group; PublicShareHeaders ported; the remaining Phase-6-scope route groups (jwt_locale, onboarding, public_invitation, public_share/public_wishlist) declared as group builders per D-15.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>

@.planning/PROJECT.md @.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-CONTEXT.md @.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-RESEARCH.md @.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-PATTERNS.md @.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-01-SUMMARY.md IMPORTANT -- naming collision this plan must avoid: summercms.go/surf/router.go (as of Phase 3, unchanged by 06-01) already declares:

package surf

// Limiter wraps handlers. Phase 6 replaces the no-op with named buckets. type Limiter interface { Wrap(http.Handler) http.Handler }

type noopLimiter struct{}

func (noopLimiter) Wrap(next http.Handler) http.Handler { return next }

func noOpLimit(next http.Handler) http.Handler { return noopLimiter{}.Wrap(next) }

This plan's concrete rate limiter is therefore named FixedWindowLimiter, NOT Limiter -- declaring type Limiter struct{...} in the same package would be an illegal redeclaration. Resolution (CONTEXT D-03: the real limiter lives "behind the existing surf.Limiter seam"):

  • The existing Limiter interface, its doc comment, and its single-argument Wrap(http.Handler) http.Handler shape are left EXACTLY as they are -- they are not deleted, not renamed, not reused as a base type. They remain the Phase 3 seam per D-03, available to a future call site that wants a single default limiter with no per-route bucket name.
  • FixedWindowLimiter (below) does NOT implement Limiter's Wrap method. Its shape is fundamentally different (parameterized by a bucket-name-or- inline-throttle param string, not a bare wrap), because D-05 requires rate limiting to be expressed exclusively through named/parameterized "throttle:..." middleware entries at call sites, not a blanket per-route wrapper -- there is no meaningful "default limiter with no name" in this design for Wrap to represent.
  • noopLimiter and noOpLimit are REMOVED in this plan, along with their one call site (h = noOpLimit(h) in wrap()). They existed only to satisfy that one blanket call site; once it is removed (rate limiting is now exclusively via named "throttle:..." entries), they have no remaining purpose and are deleted rather than left as dead code.

New file summercms.go/surf/limiter_store.go:

package surf

import "time"

// Store mirrors Illuminate\Cache\RateLimiter's hit/tooManyAttempts/ // availableIn control flow: a fixed window, first-hit-wins (an existing // unexpired window is never extended), with resetAttempts as a side effect // of TooManyAttempts observing an expired window. type Store interface { Hit(key string, decay time.Duration) (attempts int) TooManyAttempts(key string, max int) bool AvailableIn(key string) time.Duration }

// NewMemoryStore returns an in-process, mutex-guarded Store. sweep controls // the background expired-entry cleanup interval (memory hygiene only -- // correctness does not depend on it, since expiry is checked lazily). func NewMemoryStore(sweep time.Duration) *MemoryStore

New file summercms.go/surf/limiter.go:

package surf

import "net/http"

// Bucket is one named rate-limit definition. Key composes the limiter key // from the request (token id, IP, route param -- D-01's per-bucket rule). type Bucket struct { Name string Max int Decay time.Duration Key func(r *http.Request) string }

// BucketProvider is implemented by plugins that declare named buckets (not a // pact interface: it lives in surf and is type-asserted directly in // Assemble/BuildRouter, since pact cannot import surf without a cycle). type BucketProvider interface { Buckets() map[string]Bucket }

// FixedWindowLimiter is the concrete rate limiter (distinct from the // pre-existing surf.Limiter interface -- see the naming-collision note // above). It owns the Store, the named-bucket table, and the inline-throttle // parser, and produces the "throttle" middleware factory's per-route // pact.Middleware. type FixedWindowLimiter struct{ /* unexported: store Store; trusted []netip.Prefix; buckets map[string]Bucket */ }

// NewFixedWindowLimiter is the ONE constructor signature for this type -- // trusted is required at construction (not a later setter) because both // named-bucket Key closures (registered later via RegisterBucket) and the // inline "N,M" throttle's own key resolver need the same trusted-proxy list, // and threading it through every call site individually would risk two // different trusted lists disagreeing within one Router. func NewFixedWindowLimiter(store Store, trusted []netip.Prefix) *FixedWindowLimiter

func (l *FixedWindowLimiter) RegisterBucket(pluginID, name string, b Bucket) error

// Middleware builds the throttle: factory body. param is either a // registered bucket name (looked up in l.buckets) or a literal "N,M" pair // (inline throttle, parsed once and cached -- see ValidateThrottle). // Sequence per request (Laravel ThrottleRequests::handleRequest order): // 1. resolve the Bucket (named lookup or inline N,M with an inline key // resolver: principal id if bouncer.User(ctx) is set, else // r.Host+"|"+ClientIP(r, l.trusted)) // 2. if store.TooManyAttempts(key, max): set Retry-After/X-RateLimit-Reset/ // X-RateLimit-Limit/X-RateLimit-Remaining=0, write the 429 body, stop // 3. else store.Hit(key, decay); set X-RateLimit-Limit/-Remaining; call next func (l *FixedWindowLimiter) Middleware(param string) pact.Middleware

// ValidateThrottle is called once per route at Assemble/BuildRouter time // (not per request) so a malformed inline "N,M" or an unregistered bucket // name fails boot instead of the first live request. func (l *FixedWindowLimiter) ValidateThrottle(param string) error

New file summercms.go/surf/clientip.go:

package surf

import "net/netip"

// ClientIP is the single source of client IP for limiter keys (D-04). // RemoteAddr is used unless it parses as being inside one of trusted; // in that case the rightmost X-Forwarded-For hop NOT inside any trusted // prefix is used. An empty trusted list means RemoteAddr only. func ClientIP(r *http.Request, trusted []netip.Prefix) string

// TrustedProxies reads http.trusted_proxies (a []string of CIDRs) from cfg // and parses it once into []netip.Prefix. A malformed entry is skipped, not // fatal (logged by the caller if desired). func TrustedProxies(cfg *compass.Config) []netip.Prefix

Router change (summercms.go/surf/router.go): Router gains a limiter FixedWindowLimiter field (nil-safe: BuildRouter always sets it, so this is only nil for a Router built directly via New() outside BuildRouter, e.g. in an existing router_test.go unit test that never registers "throttle:..." -- such a route simply never reaches the throttle factory). BuildRouter (the 06-03-introduced split of Assemble, or Assemble itself if 06-03 has not run yet in this working tree -- Task 1's action names the exact call site either way) constructs one surf.NewFixedWindowLimiter(surf.NewMemoryStore(2time.Minute), surf.TrustedProxies(app.Config)) and attaches it to the Router before the BucketProvider loop, then registers the built-in factory: r.RegisterMiddlewareFactory("surf", "throttle", func(param string) pact.Middleware { return r.limiter.Middleware(param) }). The unconditional h = noOpLimit(h) line in wrap() is removed, and noopLimiter/noOpLimit are deleted (see the naming-collision note above) -- rate limiting is now expressed exclusively via named "throttle:..." middleware entries at call sites, matching D-05.

Task 1 (summercms.go): Fixed-window Store, FixedWindowLimiter, trusted-proxy ClientIP, and router wiring summercms.go/surf/limiter.go, summercms.go/surf/limiter_store.go, summercms.go/surf/clientip.go, summercms.go/surf/router.go, summercms.go/surf/limiter_test.go, summercms.go/surf/clientip_test.go, summercms.go/pact/capabilities.go summercms.go/surf/router.go (full, post-06-01 -- wrap()'s noOpLimit call site, the existing Limiter interface/noopLimiter/noOpLimit at the bottom of the file, RegisterMiddlewareFactory, Assemble) summercms.go/compass/config.go (full -- Lookup/String/Int/Bool/LoadSection for reading http.trusted_proxies) .planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-RESEARCH.md (Pitfalls 1, 3, 4, 6 and the "Fixed-window limiter Store" code example, lines 399-414) /media/nvme/dev/golem15/fonoteka/vendor/laravel/framework/src/Illuminate/Cache/RateLimiter.php (hit/tooManyAttempts/availableIn -- read directly, do not paraphrase from memory) /media/nvme/dev/golem15/fonoteka/vendor/laravel/framework/src/Illuminate/Routing/Middleware/ThrottleRequests.php (handleRequest control-flow order: tooManyAttempts check BEFORE hit; addHeaders on the success response too; resolveRequestSignature's user-id-vs-domain+ip branch) Create limiter_store.go: counterEntry{count int; resetAt time.Time}; MemoryStore{mu sync.Mutex; entries map[string]*counterEntry; sweep time.Duration}; NewMemoryStore(sweep time.Duration) *MemoryStore starting a background goroutine (time.Ticker on sweep, or time.AfterFunc re-armed each tick) that deletes entries where time.Now().After(resetAt). Hit(key, decay): lock; if entry absent or now is after entry.resetAt, replace with a fresh {count:0, resetAt: now.Add(decay)} (first-hit-wins: an existing unexpired window is never extended); increment count; return count. TooManyAttempts(key, max): lock; if entry absent, return false; if now is after entry.resetAt, delete the entry (mirrors PHP's resetAttempts() side effect) and return false; return entry.count >= max. AvailableIn(key): lock; if entry absent, return 0; return max(0, entry.resetAt.Sub(now)).
Create limiter.go per the exact contract in the interfaces block above -- read the naming-collision note first: Bucket, BucketProvider, FixedWindowLimiter (NOT Limiter -- that name is already the pre-existing interface in router.go), NewFixedWindowLimiter(store Store, trusted []netip.Prefix) *FixedWindowLimiter (the ONE constructor signature -- trusted is a required constructor argument, never a setter, never a second optional overload), RegisterBucket (dup-fail shape identical to RegisterMiddleware's, error "surf: bucket %q already registered by %s"), Middleware(param), ValidateThrottle(param). Resolve param in this order: (1) if param matches a registered bucket name, use it; (2) else parse param as "N,M" via strings.Cut(param, ",") + strconv.Atoi on both parts (N=max attempts, M=decay in minutes, matching Laravel's throttle:N,M semantics where M is minutes) and build an ad-hoc Bucket with Max=N, Decay=time.Duration(M)*time.Minute, and Key resolving per Pitfall 4: if bouncer.User(r.Context()) is set, key is "u:"+principal ID; else key is r.Host+"|"+ClientIP(r, l.trusted) (l.trusted is the slice captured at NewFixedWindowLimiter construction time -- do not re-read config per request); (3) else ValidateThrottle/Middleware returns an error (surfaced at Assemble/BuildRouter time via the factory's first invocation during Router.wrap()'s per-route validation loop, not deferred to the first live request -- Assemble already calls r.wrap(rt) once per route after Routes() collection specifically to catch unknown-middleware errors at boot; the same loop now also catches unknown bucket/malformed inline-throttle errors). Request-time sequence inside Middleware's returned handler: call store.TooManyAttempts(key, max) FIRST; if true, compute retryAfter := store.AvailableIn(key), set headers Retry-After (seconds, integer), X-RateLimit-Reset (unix timestamp of now+retryAfter), X-RateLimit-Limit (max), X-RateLimit-Remaining ("0"), write status 429 with body {"message":"Too Many Attempts."} (the house-default shape per Pitfall 2 -- PublicShareHeaders rewrites this for its own group in Task 2), and return without calling next; else call attempts := store.Hit(key, decay), set X-RateLimit-Limit and X-RateLimit-Remaining (max(0, max-attempts)) on the response, then call next.ServeHTTP.

Create clientip.go: ClientIP(r, trusted) -- parse r.RemoteAddr via net.SplitHostPort to get the bare IP; if trusted is empty or the parsed IP does not match any prefix in trusted (netip.Prefix.Contains), return that IP; otherwise read X-Forwarded-For, split on comma, trim each hop, walk the list RIGHT TO LEFT, return the first hop whose parsed address is NOT contained in any trusted prefix; if every hop is trusted (or the header is empty/absent), fall back to the original RemoteAddr IP. TrustedProxies(cfg *compass.Config): read http.trusted_proxies as a []string (mirror corsOrigins's []string/[]any type-switch pattern already in router.go), netip.ParsePrefix each entry, skip invalid entries, return the slice (nil/empty when the key is absent).

In router.go: add a limiter *FixedWindowLimiter field to Router. In Assemble() (before 06-03 introduces the BuildRouter split -- if 06-03 has already executed in this working tree when this task runs, make the equivalent change in BuildRouter instead, since Assemble will then just call BuildRouter+compile), after constructing r := New(corsOrigins(app)), build trusted := TrustedProxies(app.Config), lim := NewFixedWindowLimiter(NewMemoryStore(2*time.Minute), trusted) (document the 2-minute sweep default inline as "longest bucket decay is 1 minute; sweep at 2x"), set r.limiter = lim, and call r.RegisterMiddlewareFactory("surf", "throttle", func(param string) pact.Middleware { return lim.Middleware(param) }) before the existing HasMiddleware/HasMiddlewareFactories/BucketProvider loops (BucketProvider loop is new: for each plugin implementing surf.BucketProvider, call lim.RegisterBucket(p.ID(), name, b) for every entry). Remove the unconditional h = noOpLimit(h) line from wrap(). Delete the now-dead noopLimiter type and noOpLimit function entirely, and keep the existing Limiter interface declaration as the Phase 3 seam, changing only its doc comment (which currently claims "Phase 6 replaces the no-op with named buckets" and would be stale) to state that Limiter is a retained seam with no implementer after Phase 6 and that rate limiting is provided by FixedWindowLimiter through the parameterized throttle middleware (D-03, D-05) (per the interfaces block's naming-collision note; confirm via grep that noOpLimit/noopLimiter have no other call sites in the repo before deleting -- there are none as of Phase 3/06-01).
cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && go vet ./... && go test ./surf/... -run TestFixedWindowLimiter -short && go test ./surf/... -run TestClientIP -short - go build ./... succeeds with both the pre-existing surf.Limiter interface (unchanged, still declared) and the new surf.FixedWindowLimiter type coexisting in package surf -- no redeclaration error. - A fuzz-free unit test drives MemoryStore directly and asserts: three Hit calls within one decay window return counts 1,2,3; TooManyAttempts is false at count==max-1 and true at count==max; after the window elapses, TooManyAttempts resets to false and a subsequent Hit reopens a fresh window (first-hit-wins, not extended). - A test asserts X-RateLimit-Limit/X-RateLimit-Remaining are present on a SUCCESSFUL (200) response that passed through a throttle: middleware, not just on the 429. - A test asserts the 429 response carries Retry-After, X-RateLimit-Reset, X-RateLimit-Limit, and X-RateLimit-Remaining=0, with body {"message":"Too Many Attempts."}. - A test registers two buckets and stacks "throttle:bucket-a","throttle:bucket-b" on one route; exhausting bucket-a's budget alone (while bucket-b still has room) returns 429; a separate test exhausting only bucket-b (bucket-a fresh) also returns 429 -- both budgets are enforced independently. - ClientIP tests: RemoteAddr used when trusted is empty; RemoteAddr used when RemoteAddr is untrusted even if X-Forwarded-For is present (spoofing test); rightmost untrusted hop used when RemoteAddr is trusted and X-Forwarded-For has a mixed trusted/untrusted chain. - An inline "throttle:10,1" test asserts the key differs for two different bouncer.User(ctx) principals sharing one IP, but is IDENTICAL for two anonymous requests from the same IP+Host (Pitfall 4). - grep -rn "noopLimiter\|noOpLimit" summercms.go/surf/ returns zero matches after this task (confirms full removal, not a half-deleted call site). surf.FixedWindowLimiter reproduces Laravel's fixed-window ThrottleRequests wire contract exactly, including header placement and stacking, sitting alongside the untouched pre-existing surf.Limiter interface seam; ClientIP is the single trusted-proxy-aware resolver; noopLimiter/noOpLimit are fully removed. Task 2 (fonoteka.go): Register the five buckets, attach throttle:fonoteka-api-token, port PublicShareHeaders, declare the remaining route groups fonoteka.go/plugins/golem15/fonoteka/plugin.go, fonoteka.go/plugins/golem15/fonoteka/routes.go, fonoteka.go/plugins/golem15/fonoteka/middleware/public_share_headers.go, fonoteka.go/plugins/golem15/fonoteka/middleware/public_share_headers_test.go, fonoteka.go/config/http.yaml summercms.go/surf/limiter.go, summercms.go/surf/clientip.go (Task 1 output) fonoteka.go/plugins/golem15/fonoteka/plugin.go (post-06-01 -- Boot/Middlewares/MiddlewareFactories to extend) fonoteka.go/plugins/golem15/fonoteka/routes.go (post-06-01 -- the two existing genres groups) /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/routes.php lines 34-67, 333-431 (bucket definitions, jwt_locale group, onboarding group, invitation inspection route, public-share/public-wishlist group, PublicShareHeaders wiring) /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/middleware/PublicShareHeaders.php (full -- exact headers and 429 rewrite) fonoteka.go/parity/manifest.yaml lines 1-9 (the seven auth_groups: jwt_locale, jwt, onboarding, public_invitation, public_share, personal_token, oauth) In plugin.go, add Buckets() map[string]surf.Bucket implementing surf.BucketProvider, building each Key closure with a trusted := surf.TrustedProxies(p.app.Config) captured once: "fonoteka-api-token" {Max:60, Decay:time.Minute, Key: func(r){ if tok, ok := bouncer.Credential(r.Context()).(*models.ApiToken); ok { return "tok:"+strconv.FormatUint(uint64(tok.ID),10) }; return surf.ClientIP(r, trusted) }} (note: this bucket sits at GROUP level in PHP, meaning the inv_token guard middleware must run BEFORE this bucket's Key resolver sees a credential -- confirm the group's middleware order in routes.go is surf.Use("inv_token", "inv.scope:", "throttle:fonoteka-api-token") so bouncer.Credential is already populated); "fonoteka-oauth-token" {Max:30, Decay:time.Minute, Key: func(r){ return "oauthtok:"+surf.ClientIP(r, trusted) }}; "fonoteka-oauth-register" {Max:30, Decay:time.Minute, Key: func(r){ return "oauthreg:"+surf.ClientIP(r, trusted) }}; "fonoteka-public-token" {Max:60, Decay:time.Minute, Key: func(r){ return "pubtok:"+r.PathValue("token") }}; "fonoteka-public-ip" {Max:120, Decay:time.Minute, Key: func(r){ return surf.ClientIP(r, trusted) }}. Register these by returning them from Buckets() (Assemble's/BuildRouter's new BucketProvider loop calls lim.RegisterBucket(p.ID(), name, b) for each).
In routes.go: on the existing personal-token r.Group("/api/v1/fonoteka", ...) call from 06-01, change surf.Use("inv_token", "inv.scope:read") to surf.Use("inv_token", "inv.scope:read", "throttle:fonoteka-api-token") and remove the "// TODO(06-02)" comment left by 06-01 -- this is the landing spot it named. Add four more r.Group calls, each with ZERO g.Get/g.Post calls inside (empty body -- no ported handler exists for any route in these groups yet; the group declaration itself is what D-15 requires, and its middleware STRING LIST is what a boot-time smoke test (Task 3) proves resolves without error):
  - r.Group("/_fonoteka/api/v1", surf.Use("jwt.auth"), func(g pact.Router) {}) for jwt_locale (PHP's me/locale group carries jwt.auth+bindings only, no inv.must-change-password -- do not add inv.must-change-password here even though the main jwt group has it).
  - r.Group("/_fonoteka/api/v1", surf.Use("throttle:10,1"), func(g pact.Router) {}) for onboarding.
  - r.Group("/_fonoteka/api/v1/invitations", surf.Use("throttle:10,1"), func(g pact.Router) {}) for public_invitation (PHP registers this as a single ungrouped Route::get with inline middleware -- represented here as a one-off empty group for the manifest's auth_group grouping).
  - r.Group("/_fonoteka/api/v1", surf.Use("public.share-headers", "throttle:fonoteka-public-token", "throttle:fonoteka-public-ip"), func(g pact.Router) {}) for public_share/public_wishlist (both PHP prefixes share one middleware stack; one Go group declaration covers both per D-15's "framework fixture routes in tests" allowance for proving behavior without a real handler).
Add a package comment above these four calls citing routes.php's exact line ranges and noting each is deliberately empty pending its real handler in a later phase (not a 501 shell: zero routes are registered at all).

Create middleware/public_share_headers.go: PublicShareHeaders(next http.Handler) http.Handler (fixed middleware, not a factory -- register it in Middlewares() under the name "public.share-headers"). Wrap next in an http.ResponseWriter interceptor (buffer the status code via a small responseRecorder wrapper, matching the codebase's plain-stdlib style) so that: if the final status is 429, rewrite the body to {"error":"Too many requests"} while copying Retry-After/X-RateLimit-Limit/X-RateLimit-Remaining/X-RateLimit-Reset from whatever the limiter already set; on EVERY response (429 or not) set X-Robots-Tag: noindex, nofollow and Cache-Control: private, no-store (exact PHP string order, matching PublicShareHeaders.php lines 50-54) after next.ServeHTTP returns control, since Go's http.ResponseWriter forbids setting headers after WriteHeader is called -- design the wrapper to capture headers/status BEFORE flushing to the real ResponseWriter (buffer the whole response in memory, matching this middleware's small, bounded-size use case, then write final headers + body once).

In plugin.go's Middlewares(), add "public.share-headers": middleware.PublicShareHeaders to the returned map.

In fonoteka.go/config/http.yaml, add a trusted_proxies: [] key (empty list, matching "no proxies trusted yet" -- an operator populates this in a later phase's deployment work) nested appropriately so it resolves to http.trusted_proxies via compass's section-naming (file is already the "http" section, so add a top-level trusted_proxies: [] key alongside the existing cors: block).
cd /media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go && go vet ./... && go test ./plugins/golem15/fonoteka/... -run TestPublicShareHeaders -short - go test ./plugins/golem15/fonoteka/middleware/... -run TestPublicShareHeaders asserts: a 429 from an inner handler is rewritten to {"error":"Too many requests"} with Retry-After/X-RateLimit-* preserved; a 200 response still gets X-Robots-Tag and Cache-Control but its body is untouched. - grep -n "fonoteka-api-token\|fonoteka-oauth-token\|fonoteka-oauth-register\|fonoteka-public-token\|fonoteka-public-ip" fonoteka.go/plugins/golem15/fonoteka/plugin.go shows all five bucket names with their exact Max values (60, 30, 30, 60, 120). - The personal-token genres route's middleware list (readable via the route table once 06-03 exists, or directly via a Task-3 boot-smoke test in the meantime) includes "throttle:fonoteka-api-token" as the last entry. All five buckets are registered and available; the token group is throttled; the public-share group's 429 shape is ported; the remaining four PHP route groups are declared as structurally correct, handler-free group builders. Task 3 (fonoteka.go, parity, .planning): APP_DEBUG=false fix, boot-smoke coverage for all groups, ROADMAP/REQUIREMENTS wording correction fonoteka.go/parity/php_parity.sh, fonoteka.go/plugins/golem15/fonoteka/routes_bucket_test.go, .planning/ROADMAP.md, .planning/REQUIREMENTS.md fonoteka.go/parity/php_parity.sh (full -- the export_env() function that does not currently set APP_DEBUG) fonoteka.go/parity/manifest.yaml (search for any 429/throttle/error-shaped fixture bodies that would change under APP_DEBUG=false) .planning/ROADMAP.md Phase 6 section (Success Criteria item 2: "All seven named rate-limit buckets") .planning/REQUIREMENTS.md HTTP-04 line ("ports Płytarium's seven named buckets and inline throttles 1:1") In php_parity.sh's export_env() (or the equivalent function that sets environment variables before booting the isolated PHP instance), add export APP_DEBUG=false alongside the existing exports, so any fixture recorded/re-recorded from this point on reflects the production (non-debug) error body shape per the user-resolved Open Question 2. Search manifest.yaml and fixtures/routes/*.yaml for any existing recorded fixture whose body would plausibly differ under debug vs non-debug (error/exception-shaped bodies, 4xx/5xx cases with a "trace" or "exception" key) and flag them in the plan's SUMMARY if any are found needing re-recording (this repo's CACHE_DRIVER=array means live PHP 429s specifically cannot be recorded from this harness at all -- per the user-resolved note, 429 bodies/headers are asserted directly in Go tests from the Laravel vendor source already cited in 06-RESEARCH.md, not from recorded fixtures; do not attempt to record a 429 fixture from this harness).
Create routes_bucket_test.go: a boot-smoke test that calls the same app.Handler(...)-equivalent path parity/parity_test.go's newTarget uses (or a lighter-weight surf.Assemble(app, plugins) call against a real activated plugin set) and asserts no error -- this exercises every bucket name and every "throttle:..."/"inv.scope:..."/"public.share-headers" middleware string across all six now-declared route groups (jwt, jwt_locale, onboarding, public_invitation, public_share/public_wishlist, personal_token) without needing a real handler in any of the five non-genres groups. Add a second assertion using the route table if 06-03 has already landed in this working tree (guard with a build check or skip gracefully if surf.Router.Routes() does not exist yet in this wave -- 06-02 runs before 06-03, so prefer NOT depending on Routes() here at all; the plain no-error boot assertion is sufficient for this plan).

In .planning/ROADMAP.md, Phase 6 Success Criteria item 2, change "All seven named rate-limit buckets" to "All five named rate-limit buckets". In .planning/REQUIREMENTS.md, HTTP-04's description, change "ports Płytarium's seven named buckets and inline throttles 1:1" to "ports Płytarium's five named buckets and inline throttles 1:1". Commit this docs change SEPARATELY from the code changes in this plan (per CLAUDE.md: "planning docs and code in separate commits") -- do not include these two files in the same commit as the Go/shell changes.
cd /media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go && go vet ./... && go test ./plugins/golem15/fonoteka/... -run TestAllRouteGroupsBoot -short && grep -c "APP_DEBUG=false" parity/php_parity.sh - grep -c "APP_DEBUG=false" fonoteka.go/parity/php_parity.sh returns at least 1. - The boot-smoke test passes, proving all five bucket names and every declared group's middleware list resolves at Assemble time. - grep -n "seven named rate-limit buckets\|seven named buckets" .planning/ROADMAP.md .planning/REQUIREMENTS.md returns zero matches; grep -n "five named" returns at least one match in each file. The parity harness records future fixtures against production-shaped error bodies; every Phase-6-scope route group boots cleanly with its real middleware stack; the roadmap/requirements bucket-count miscount is corrected in a docs-only commit.

<threat_model>

Trust Boundaries

Boundary Description
client -> X-Forwarded-For untrusted header, must only be honored when RemoteAddr is a configured trusted proxy
client -> rate-limit keys an attacker-controlled IP/token/route-param feeds directly into the Store's key namespace
public-share group -> unauthenticated caller the only Phase-6 surface exposed with zero credential requirement; its error responses must never leak internals

STRIDE Threat Register

Threat ID Category Component Disposition Mitigation Plan
T-06-06 Denial of Service surf.ClientIP mitigate X-Forwarded-For honored only when RemoteAddr is inside http.trusted_proxies; an untrusted caller cannot spoof their limiter key (Task 1 spoofing test)
T-06-07 Information Disclosure 429 response on the public-share group mitigate PublicShareHeaders rewrites any 429 (framework-default or otherwise) to a fixed JSON body before it reaches an anonymous caller, and sets no-index/no-store headers on every response in that group
T-06-08 Denial of Service in-process MemoryStore under high cardinality (many distinct keys, e.g. one per IP) accept v1 ships an unbounded-until-swept map per CONTEXT D-03's explicit "no otter/cooler this phase" decision; the sweep goroutine bounds long-term growth to roughly one decay window's worth of distinct keys, acceptable for a single-instance v1 deployment
T-06-09 Repudiation Recorded parity fixtures under the wrong APP_DEBUG value mitigate php_parity.sh now pins APP_DEBUG=false so all future recordings are production-shaped; this plan audits existing fixtures for drift rather than assuming none exists
</threat_model>
cd summercms.go && go vet ./... && go test ./surf/... -short cd ../fonoteka.go && go vet ./... && go test ./plugins/golem15/fonoteka/... -short

<success_criteria>

  • surf.FixedWindowLimiter reproduces Laravel's fixed-window ThrottleRequests contract, including success-response headers and stacking, alongside the untouched pre-existing surf.Limiter interface.
  • The five fonoteka buckets are registered with exact names/limits/keys; the token group is throttled by fonoteka-api-token.
  • PublicShareHeaders ports its exact 429 rewrite and unconditional headers.
  • All six Phase-6-scope route groups (everything but oauth) boot cleanly with their real middleware stacks.
  • ROADMAP.md/REQUIREMENTS.md say five buckets, corrected in a docs-only commit.
  • go vet ./... and go test ./... are green in both repos. </success_criteria>
Create `.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-02-SUMMARY.md` when done