docs(06-02): complete fixed-window limiter and five-bucket plan

This commit is contained in:
Jakub Zych
2026-09-19 19:46:28 +02:00
parent ab20ad0f9c
commit 42b8295cc1
4 changed files with 186 additions and 9 deletions

View File

@@ -53,7 +53,7 @@ Requirements for v1 (the Płytarium port). Each maps to roadmap phases. "User" b
- [x] **HTTP-01**: Plugins register route groups on net/http ServeMux with typed params and regex constraints; unknown and malformed ids both return 404 on ownership-scoped resources - [x] **HTTP-01**: Plugins register route groups on net/http ServeMux with typed params and regex constraints; unknown and malformed ids both return 404 on ownership-scoped resources
- [x] **HTTP-02**: Plugins register named middleware that other plugins reference by name; the pipeline order is recover, CORS, locale, auth group, must-change-password, org context, rate limit, handler - [x] **HTTP-02**: Plugins register named middleware that other plugins reference by name; the pipeline order is recover, CORS, locale, auth group, must-change-password, org context, rate limit, handler
- [x] **HTTP-03**: Three mutually exclusive auth groups share the same handlers with different route subsets: JWT under /_fonoteka/api/v1, personal scoped token under /api/v1/fonoteka, and public groups (onboarding, public/{token}, public-wishlist/{token}, invitation inspection) - [x] **HTTP-03**: Three mutually exclusive auth groups share the same handlers with different route subsets: JWT under /_fonoteka/api/v1, personal scoped token under /api/v1/fonoteka, and public groups (onboarding, public/{token}, public-wishlist/{token}, invitation inspection)
- [ ] **HTTP-04**: A rate limiter supports named buckets keyed by a resolver (token id, IP, route param), stacking two limiters on one route, and ports Płytarium's five named buckets and inline throttles 1:1 - [x] **HTTP-04**: A rate limiter supports named buckets keyed by a resolver (token id, IP, route param), stacking two limiters on one route, and ports Płytarium's five named buckets and inline throttles 1:1
- [x] **HTTP-05**: An auth guard registry lets plugins add guards (JWT, personal token, OAuth bearer) that all resolve to the same current-user accessor - [x] **HTTP-05**: An auth guard registry lets plugins add guards (JWT, personal token, OAuth bearer) that all resolve to the same current-user accessor
- [ ] **HTTP-06**: Response conventions are preserved: empty arrays serialize as [], timestamps as +00:00, tri-state booleans keep null, conditional keys are omitted not nulled, and no blanket envelope or error middleware wraps OAuth routes - [ ] **HTTP-06**: Response conventions are preserved: empty arrays serialize as [], timestamps as +00:00, tri-state booleans keep null, conditional keys are omitted not nulled, and no blanket envelope or error middleware wraps OAuth routes
- [ ] **HTTP-07**: A guarded outbound fetch helper enforces host allow-lists, byte caps and timeouts for user-supplied URLs (manual cover URL, Discogs cover) - [ ] **HTTP-07**: A guarded outbound fetch helper enforces host allow-lists, byte caps and timeouts for user-supplied URLs (manual cover URL, Discogs cover)
@@ -186,7 +186,7 @@ Which phases cover which requirements. Updated during roadmap creation.
| HTTP-01 | Phase 3 | Complete | | HTTP-01 | Phase 3 | Complete |
| HTTP-02 | Phase 3 | Complete | | HTTP-02 | Phase 3 | Complete |
| HTTP-03 | Phase 6 | Complete | | HTTP-03 | Phase 6 | Complete |
| HTTP-04 | Phase 6 | Pending | | HTTP-04 | Phase 6 | Complete |
| HTTP-05 | Phase 6 | Complete | | HTTP-05 | Phase 6 | Complete |
| HTTP-06 | Phase 6 | Pending | | HTTP-06 | Phase 6 | Pending |
| HTTP-07 | Phase 6 | Pending | | HTTP-07 | Phase 6 | Pending |

View File

@@ -233,7 +233,7 @@ Plans:
**Wave 2** *(blocked on 06-01)* **Wave 2** *(blocked on 06-01)*
- [ ] 06-02-PLAN.md — Rate limiting: fixed-window Store/Limiter, trusted-proxy client IP, five fonoteka buckets, PublicShareHeaders, remaining route groups declared - [x] 06-02-PLAN.md — Rate limiting: fixed-window Store/Limiter, trusted-proxy client IP, five fonoteka buckets, PublicShareHeaders, remaining route groups declared
**Wave 3** *(blocked on 06-02)* **Wave 3** *(blocked on 06-02)*
@@ -410,7 +410,7 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 →
| 3. First vertical slice — genres end to end | 4/4 | Complete | 2026-09-17 | | 3. First vertical slice — genres end to end | 4/4 | Complete | 2026-09-17 |
| 4. CLI scaffolding, i18n and mail | 4/4 | Complete | 2026-09-18 | | 4. CLI scaffolding, i18n and mail | 4/4 | Complete | 2026-09-18 |
| 5. Data layer full fidelity | 6/6 | Complete | 2026-09-18 | | 5. Data layer full fidelity | 6/6 | Complete | 2026-09-18 |
| 6. HTTP routing, auth groups and rate limiting | 2/5 | In Progress| | | 6. HTTP routing, auth groups and rate limiting | 3/5 | In Progress| |
| 7. User plugin and authentication | 0/TBD | Not started | - | | 7. User plugin and authentication | 0/TBD | Not started | - |
| 8. OAuth2.1 authorization server | 0/TBD | Not started | - | | 8. OAuth2.1 authorization server | 0/TBD | Not started | - |
| 9. Backend admin authentication and schema pipeline | 0/TBD | Not started | - | | 9. Backend admin authentication and schema pipeline | 0/TBD | Not started | - |

View File

@@ -4,13 +4,13 @@ milestone: v1.0
milestone_name: milestone milestone_name: milestone
status: executing status: executing
stopped_at: Phase 6 context gathered stopped_at: Phase 6 context gathered
last_updated: "2026-09-19T17:23:58.639Z" last_updated: "2026-09-19T17:46:28.583Z"
last_activity: 2026-09-19 last_activity: 2026-09-19
progress: progress:
total_phases: 15 total_phases: 15
completed_phases: 5 completed_phases: 5
total_plans: 28 total_plans: 28
completed_plans: 24 completed_plans: 26
percent: 33 percent: 33
--- ---
@@ -26,11 +26,11 @@ See: .planning/PROJECT.md (updated 2026-09-16)
## Current Position ## Current Position
Phase: 06 (http-routing-auth-groups-and-rate-limiting) — EXECUTING Phase: 06 (http-routing-auth-groups-and-rate-limiting) — EXECUTING
Plan: 2 of 5 Plan: 3 of 5
Status: Ready to execute Status: Ready to execute
Last activity: 2026-09-19 Last activity: 2026-09-19
Progress: [█████████░] 86% Progress: [█████████░] 93%
## Performance Metrics ## Performance Metrics
@@ -71,6 +71,7 @@ Progress: [█████████░] 86%
| Phase 05 P05 | 18 min | 3 tasks | 18 files | | Phase 05 P05 | 18 min | 3 tasks | 18 files |
| Phase 05 P06 | 23 min | 3 tasks | 12 files | | Phase 05 P06 | 23 min | 3 tasks | 12 files |
| Phase 06 P01 | 25 min | 3 tasks | 26 files | | Phase 06 P01 | 25 min | 3 tasks | 26 files |
| Phase 06 P02 | 14 min | 3 tasks | 16 files |
## Accumulated Context ## Accumulated Context
@@ -156,6 +157,11 @@ Recent decisions affecting current work:
- [Phase 06]: NewJWTGuard reuses bearerToken/Verify/write401 so Registry.Middleware(jwt) is byte-identical to bouncer.Middleware (D-10) - [Phase 06]: NewJWTGuard reuses bearerToken/Verify/write401 so Registry.Middleware(jwt) is byte-identical to bouncer.Middleware (D-10)
- [Phase 06]: oauth is not registered this plan; only jwt and inv_token (D-09) - [Phase 06]: oauth is not registered this plan; only jwt and inv_token (D-09)
- [Phase 06]: Personal-token genres fixture body matches isolated seedGenres; CORS * deferred to D-18 - [Phase 06]: Personal-token genres fixture body matches isolated seedGenres; CORS * deferred to D-18
- [Phase 06]: Concrete limiter is FixedWindowLimiter; surf.Limiter interface remains the unused Phase 3 seam — D-03 D-05 naming collision with pre-existing Limiter interface
- [Phase 06]: Trusted-proxy list is a NewFixedWindowLimiter constructor argument, never a setter — Named-bucket Key closures and inline N,M must share one trusted list
- [Phase 06]: fonoteka-* buckets live on the app plugin via surf.BucketProvider, not hardcoded in surf — summercms.go must stay Płytarium-agnostic
- [Phase 06]: Empty PHP group builders plus test-only boot-probe routes prove middleware strings resolve without 501 shells — D-15: no 501 shells; wrap() only sees routes
- [Phase 06]: php_parity.sh pins APP_DEBUG=false; three existing HTML exception fixtures need re-recording — T-06-09 production-shaped error bodies
### Pending Todos ### Pending Todos
@@ -177,6 +183,6 @@ Items acknowledged and carried forward from previous milestone close:
## Session Continuity ## Session Continuity
Last session: 2026-09-19T17:19:44.685Z Last session: 2026-09-19T17:45:48.542Z
Stopped at: Phase 6 context gathered Stopped at: Phase 6 context gathered
Resume file: None Resume file: None

View File

@@ -0,0 +1,171 @@
---
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:<principal id> 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*