310 lines
31 KiB
Markdown
310 lines
31 KiB
Markdown
---
|
|
phase: 06-http-routing-auth-groups-and-rate-limiting
|
|
plan: 02
|
|
type: execute
|
|
wave: 2
|
|
depends_on: ["06-01"]
|
|
files_modified:
|
|
- 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
|
|
autonomous: true
|
|
requirements: [HTTP-04]
|
|
|
|
must_haves:
|
|
truths:
|
|
- "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)"
|
|
artifacts:
|
|
- path: summercms.go/surf/limiter_store.go
|
|
provides: "Store interface + MemoryStore: Hit, TooManyAttempts, AvailableIn matching Illuminate\\Cache\\RateLimiter semantics"
|
|
- path: summercms.go/surf/limiter.go
|
|
provides: "Bucket type, Limiter, RegisterBucket, the throttle: middleware factory wired into surf.Router"
|
|
- path: summercms.go/surf/clientip.go
|
|
provides: "ClientIP(r, trusted) and TrustedProxies(cfg) -- the single source of client IP for limiter keys"
|
|
- path: fonoteka.go/plugins/golem15/fonoteka/middleware/public_share_headers.go
|
|
provides: "PublicShareHeaders: X-Robots-Tag/Cache-Control on every response, 429 body rewrite with header preservation"
|
|
key_links:
|
|
- from: fonoteka.go/plugins/golem15/fonoteka/plugin.go
|
|
to: summercms.go/surf/limiter.go
|
|
via: "Plugin implements surf.BucketProvider, registering the five buckets at Boot"
|
|
pattern: "Buckets\\(\\) map\\[string\\]surf\\.Bucket"
|
|
- from: summercms.go/surf/router.go
|
|
to: summercms.go/surf/limiter.go
|
|
via: "Router registers a built-in \"throttle\" middleware factory bound to its Limiter"
|
|
pattern: "RegisterMiddlewareFactory\\(.*\"throttle\""
|
|
---
|
|
|
|
<objective>
|
|
Replace the Phase 3 no-op limiter seam 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.Limiter`/`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.
|
|
</objective>
|
|
|
|
<execution_context>
|
|
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
|
@$HOME/.claude/get-shit-done/templates/summary.md
|
|
</execution_context>
|
|
|
|
<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
|
|
</context>
|
|
|
|
<interfaces>
|
|
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, since pact cannot import surf without a cycle).
|
|
type BucketProvider interface {
|
|
Buckets() map[string]Bucket
|
|
}
|
|
|
|
type Limiter struct{ /* unexported: store Store; buckets map[string]Bucket */ }
|
|
|
|
func NewLimiter(store Store) *Limiter
|
|
func (l *Limiter) 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)
|
|
// 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 *Limiter) Middleware(param string) pact.Middleware
|
|
|
|
// ValidateThrottle is called once per route at Assemble/wrap 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 *Limiter) 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 *Limiter
|
|
field (nil-safe: a nil limiter behaves like today's noopLimiter). Assemble
|
|
constructs one surf.NewLimiter(surf.NewMemoryStore(2*time.Minute)) 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 -- rate limiting
|
|
is now expressed exclusively via named "throttle:..." middleware entries at
|
|
call sites, matching D-05.
|
|
</interfaces>
|
|
|
|
<tasks>
|
|
|
|
<task type="auto">
|
|
<name>Task 1 (summercms.go): Fixed-window Store, Limiter, trusted-proxy ClientIP, and router wiring</name>
|
|
<files>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</files>
|
|
<read_first>
|
|
summercms.go/surf/router.go (full, post-06-01 -- wrap()'s noOpLimit call site, 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)
|
|
</read_first>
|
|
<action>
|
|
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: Bucket, BucketProvider, Limiter, NewLimiter, 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, trusted) (the inline-throttle Limiter needs a trusted-proxy list too -- thread it through NewLimiter's constructor or a setter, e.g. NewLimiter(store Store, trusted []netip.Prefix)); (3) else ValidateThrottle/Middleware returns an error (surfaced at Assemble 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 *Limiter field to Router; in Assemble(), after constructing r := New(corsOrigins(app)), build trusted := TrustedProxies(app.Config), lim := NewLimiter(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() (rate limiting is now exclusively expressed via "throttle:..." middleware entries); delete noOpLimit/noopLimiter only if nothing else references them, otherwise leave them unused-but-harmless is NOT acceptable in Go (unused private funcs are fine, but confirm via go vet/staticcheck that removing the call site doesn't orphan an exported symbol other packages depend on -- grep first).
|
|
</action>
|
|
<verify>
|
|
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && go vet ./... && go test ./surf/... -run TestLimiter -short && go test ./surf/... -run TestClientIP -short</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- 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).
|
|
</acceptance_criteria>
|
|
<done>surf.Limiter reproduces Laravel's fixed-window ThrottleRequests wire contract exactly, including header placement and stacking; ClientIP is the single trusted-proxy-aware resolver.</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 2 (fonoteka.go): Register the five buckets, attach throttle:fonoteka-api-token, port PublicShareHeaders, declare the remaining route groups</name>
|
|
<files>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</files>
|
|
<read_first>
|
|
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)
|
|
</read_first>
|
|
<action>
|
|
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:<x>", "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 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).
|
|
</action>
|
|
<verify>
|
|
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go && go vet ./... && go test ./plugins/golem15/fonoteka/... -run TestPublicShareHeaders -short</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- 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.
|
|
</acceptance_criteria>
|
|
<done>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.</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 3 (fonoteka.go, parity, .planning): APP_DEBUG=false fix, boot-smoke coverage for all groups, ROADMAP/REQUIREMENTS wording correction</name>
|
|
<files>fonoteka.go/parity/php_parity.sh, fonoteka.go/plugins/golem15/fonoteka/routes_bucket_test.go, .planning/ROADMAP.md, .planning/REQUIREMENTS.md</files>
|
|
<read_first>
|
|
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")
|
|
</read_first>
|
|
<action>
|
|
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.
|
|
</action>
|
|
<verify>
|
|
<automated>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</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- 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.
|
|
</acceptance_criteria>
|
|
<done>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.</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<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>
|
|
|
|
<verification>
|
|
cd summercms.go && go vet ./... && go test ./surf/... -short
|
|
cd ../fonoteka.go && go vet ./... && go test ./plugins/golem15/fonoteka/... -short
|
|
</verification>
|
|
|
|
<success_criteria>
|
|
- surf.Limiter reproduces Laravel's fixed-window ThrottleRequests contract, including success-response headers and stacking.
|
|
- 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>
|
|
|
|
<output>
|
|
Create `.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-02-SUMMARY.md` when done
|
|
</output>
|