|
|
|
|
@@ -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*
|