15 KiB
Phase 6: HTTP routing, auth groups and rate limiting - Context
Gathered: 2026-09-19 Status: Ready for planning
## Phase BoundaryPhase 6 turns the Phase 3 routing skeleton into the full HTTP contract layer: a guard registry in bouncer with two real guards (JWT, personal token) resolving to one current-user accessor; the three mutually exclusive auth groups (JWT under /_fonoteka/api/v1, personal token under /api/v1/fonoteka, public/onboarding) sharing handlers; a real rate limiter behind the existing surf.Limiter seam with named buckets, inline throttles and stacking; response-convention helpers; structural exemption of OAuth/RFC routes from house envelope and error middleware; an SSRF-guarded outbound fetch helper; swaggo/swag OpenAPI generation with an openapi-typescript check; CORS and JSON body-size parity. Requirements HTTP-03 through HTTP-09. Roadmap mode: mvp. Security-load-bearing: the security-review agent runs on it.
Two repos. summercms.go gains the limiter, guard registry, raw-group flag and route table, response helpers/types, fetch helper, path-scoped CORS and body limits — and still knows nothing about Płytarium. fonoteka.go gains the inv_token guard, inv.scope, the five fonoteka buckets, the group builders with PHP's middleware stacks, and genres mounted under both prefixes.
Not in this phase: token CRUD and minting, register/login/refresh (Phase 7); the OAuth bearer guard and any OAuth handler (Phase 8); the bulk of the 154 routes (Phases 12–14); the manual-cover and Discogs cover fetchers and their endpoints (Phase 12/14); buckets owned by the user, feedback and websockets plugins (their own phases); HTTP DTO fuzzing (Phase 12).
## Implementation DecisionsRate-limit buckets and limiter
- D-01: Phase 6 defines and tests fonoteka's five named buckets exactly as in
routes.php—fonoteka-api-token(60/min, keytok:<token id>else client 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, client IP) — plus the inlinethrottle:N,Mmechanism (PHP uses10,1,12,1,20,1,60,1) and stacking of two limiters on one route (fonoteka-public-token+fonoteka-public-ip). The roadmap's "seven named buckets" is a miscount: correct the wording in ROADMAP.md and REQUIREMENTS.md HTTP-04 at plan time. Buckets ofgolem15.user(user-api,pin-login,2fa-verify), feedback and websockets are declared by those plugins in their own phases through the same API. - D-02: Exact Laravel
ThrottleRequestswire parity: fixed window per key (first hit opens the window),X-RateLimit-LimitandX-RateLimit-Remainingon limited responses,Retry-AfterandX-RateLimit-Reseton 429, and the same 429 body PHP returns. Researcher confirms header set, body and inline-throttle key composition from the Laravel version the PHP app runs. - D-03: Counters live in-process, stdlib only: a mutex-guarded map with expiry sweep behind
surf.Limiter, with a small Store interface so a shared backend can come later. Nootter, nocoolerpackage this phase. - D-04: Client IP comes from a trusted-proxy rule: a
http.trusted_proxiesCIDR list;X-Forwarded-Foris honored only whenRemoteAddris inside that list, taking the rightmost untrusted hop; empty list meansRemoteAddronly. One framework function is the single source of client IP for limiter keys (and later logging). - D-05: Parameterized middleware names are a
surffeature:throttle:10,1,throttle:fonoteka-public-ipandinv.scope:writeare written at call sites exactly as in PHP, so the routes port stays line by line (extends Phase 3 D-14).
Guard registry and personal-token guard
- D-06:
bouncergets a named guard registry. Plugins register at Register time under a name (jwt,inv_token;oauthlater) a Guard with an authenticate-request → principal shape. Auth middleware is derived from a guard by name. Every guard writes the samebouncer.User(ctx)accessor, plus an optional credential accessor (the resolved token) thatinv.scopeand thetok:<id>bucket key read. Duplicate or unknown guard names fail boot. - D-07: The personal-token guard is real; only minting is test-only (same split as Phase 3 D-07). It verifies the presented token against the Phase 5
ApiTokenmodel with the PHP guard's rules (hashing, expiry, revocation, last-used) and resolves the real user. Tokens are inserted by tests and parity seed hooks. Phase 7 adds CRUD and keeps this verifier. - D-08:
inv.scope:<read|write|ai>lands in Phase 6 in thegolem15.fonotekaplugin, as in PHP (TokenScope.php): it is the token group's auth step. Bodies are exact: 401{"error":"Invalid token"}, 403{"error":"Missing required scope: <scope>"}. Theinv_tokenguard is registered bygolem15.fonotekatoo, mirroring PHP ownership. - D-09: The
oauthguard name is reserved in docs only. No stub that pretends to validate bearer tokens ships; the registry is proven with the two real guards. Phase 8 registersoauth. - D-10: The Phase 3 JWT verifier, its 401 bodies and the 423 gate are kept unchanged;
jwt.authis re-expressed as thejwtguard in the registry without changing behavior.
Outbound fetch helper
- D-11: One framework helper with a per-call Policy offering both PHP modes:
AllowHosts(exact and dotted-suffix match, soevil-discogs.comnever passes) andPublicOnly(any host, every resolved IP must be public). The private/loopback/reserved IP block is always on, including under an allow-list. https only, redirects disabled, byte cap enforced while streaming, overall timeout. Roadmap criterion 4 is tested through the allow-list mode; manual cover URL keeps PHP's any-public-host behavior. - D-12: The IP check happens at dial time (
net.DialerControl hook on the actual address being connected), closing the resolve-then-connect rebinding gap PHP's comments admit. Stdlib only. This is invisible on the wire and not a contract change. - D-13: Phase 6 ships the helper and its tests only (against
httptestservers), with typed failure reasons that map onto PHP'sinvalid_url/unresolvable/private_ip/network_error/too_largefamily.ManualCoverUrlFetcherandCoverImporterports are Phase 12/14 call sites. - D-14: Byte cap and timeout have framework defaults in config (
http.fetch.*, defaulting to PHP's 10 MiB and 10 s), overridable per call. A call can lower or raise them but never reach "unlimited": zero or negative effective values are an error.
Route surface, OAuth exemption, conventions, CORS
- D-15: All seven PHP route groups exist in
fonoteka.goas group builders with their exact middleware stacks, but only routes with real handlers are mounted. The shared-handler proof isGET genresunder both/_fonoteka/api/v1(JWT) and/api/v1/fonoteka(inv.scope:read), which flipsGET__api_v1_fonoteka_genres_personal_tokento ported. Public-group and stacked-limiter behavior is proven on a real route if one is cheaply portable, otherwise on framework fixture routes in tests. No 501 shells; manifest entries staypendinguntil a real handler exists (pending never equals passing). - D-16: OAuth/RFC exemption is a group flag enforced at registration: a raw group refuses any middleware tagged as house envelope/error and fails boot if one is named; recover on a raw group emits a bare 500 with no house JSON body. The router exposes a route table (method, pattern, plugin, middleware chain, raw flag) used by tests and a
route:listcommand; success criterion 3's "verified by route-registration inspection" is a test over that table. The/.well-known/oauth-authorization-serverand/oauth/mcp/*group is declared raw now with its two throttles, handlers arrive in Phase 8. - D-17: Response conventions are framework helpers and types, not middleware: a JSON writer plus small types (Carbon-format
+00:00time, nullable tri-state bool, never-nil slice helper); handlers build DTOs explicitly and omit conditional keys. Nothing wraps or rewrites responses after the handler. Tested at type level and on the genres routes. - D-18: CORS becomes path-scoped and config-driven like Laravel (
paths, origins, methods, headers,max_age, credentials). Fonoteka's config reproducesconfig/cors.phpexactly — including that_fonoteka/api/*is not listed, so the JWT group gets no CORS headers, as today. JSON body limits usehttp.MaxBytesReaderper group with a larger cap for upload routes; the researcher reads the real PHP/nginx deployment values rather than guessing.
Claude's Discretion
- Package homes and names (where the fetch helper, client-IP function and response types live), limiter sweep interval, Store interface shape.
- Inline
throttle:N,Mkey composition, as long as it matches Laravel's (D-02 research). - swag workflow: which repo holds annotations and generated spec, the
summercommand or script that runs swag andopenapi-typescript, and a drift check. Default: annotations on the real fonoteka handlers, generated spec committed infonoteka.go, types validity checked in the phase gate script. - Locale stage depth beyond Phase 3's
Accept-Languageread, only as far as the ported routes need. - How mutual exclusivity of groups is asserted (route-table test preferred).
- Plan count and split, subject to the plan-count checkpoint and "unit tests are the last plan"; security-review agent included.
<canonical_refs>
Canonical References
Downstream agents MUST read these before planning or implementing.
The PHP contract
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/routes.php— lines 37–53 and 390–396 (the fiveRateLimiter::forbuckets and the reasoning comments), 61–80 (JWT groups), 351–364 (onboarding/invitation public group,throttle:10,1), 399–426 (public share groups,PublicShareHeaders, stacked limiters), 449–514 (token group,inv.scope, genres at 513), 547–565 (OAuth group and its throttles), and every inlinethrottle:call site./media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/middleware/TokenScope.php— 401/403 bodies and guard usage (D-08)./media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/Plugin.phparound lines 121 and 262, and/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/auth/— theinv_tokenguard registration and implementation (D-07)./media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/middleware/PublicShareHeaders.php— headers on public share routes./media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/ManualCoverUrlFetcher.phpandclasses/discogs/CoverImporter.php— the two SSRF policies, failure reasons, 10 MiB / 10 s defaults, no redirects (D-11–D-14)./media/nvme/dev/golem15/fonoteka/config/cors.php— exact CORS parity target (D-18);/media/nvme/dev/golem15/fonoteka/config/cache.php— file cache driver behind PHP's limiter./media/nvme/dev/golem15/fonoteka/plugins/golem15/user/middleware/JwtAuthenticate.php— unchanged JWT contract (D-10)./media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/traits/SerializesFonoteka.php— source of the response conventions (D-17).
Parity harness
../fonoteka.go/parity/fixtures/routes/GET__api_v1_fonoteka_genres_personal_token.yaml,GET__api_v1_fonoteka_me_personal_token.yaml,GET___fonoteka_api_v1_public_{token}_*.yaml,GET___fonoteka_api_v1_invitations_{token}_public_invitation.yaml— fixtures for the token and public groups.../fonoteka.go/parity/manifest.yaml,../fonoteka.go/parity/parity_test.go— pending/ported status and seed hooks (token insertion for D-07).
Existing framework code
surf/router.go— group builder, named middleware,Limiter/noopLimiterseam,cors,recoverJSON,Assemble;surf/params.go— 404 rule.bouncer/jwt.go,bouncer/context.go— verifier,Principal,WithUser/User.../fonoteka.go/plugins/golem15/fonoteka/models/api_token.go— Phase 5 model the guard reads.
Research and prior decisions
.planning/research/PITFALLS.md§Rate-limit bucket parity, §Pitfall 4 (nil slice), §Pitfall 8 (envelopes and error shapes), §Security Mistakes..planning/research/ARCHITECTURE.md§Request Flow (auth groups; notecooler/otter is superseded by D-03 for this phase)..planning/research/STACK.md§OpenAPI generation (swaggo/swag v1.16.6, openapi-typescript)..planning/phases/03-first-vertical-slice-genres-end-to-end/03-CONTEXT.mdD-07, D-09, D-10, D-12, D-14, D-15 — what Phase 3 left as seams for this phase..planning/REQUIREMENTS.mdHTTP-03–HTTP-09;.planning/ROADMAP.mdPhase 6;CLAUDE.mdGSD lean-mode rules.
</canonical_refs>
<code_context>
Existing Code Insights
Reusable Assets
surf.Router/GroupwithUse,Where,WhereIn, fail-boot on unknown middleware names; onlyGetexists today — other verbs are needed for later phases but only as far as mounted routes require.surf.Limiterinterface withnoopLimiterin the fixed pipeline slot: the real limiter drops in here.bouncer.Middleware,Verify,Principal,User(ctx): the JWT guard to wrap into the registry.ApiToken,OAuthClient,OAuthAuthCode,OAuthRefreshTokenmodels (Phase 5).- Parity
seedHooksseam and the genres JWT route already ported and green.
Established Patterns
- Stdlib first; new dependency this phase is only
swaggo/swag(STACK-named). Limiter, fetch guard and CORS are stdlib. - Request state in
context.Contextwith unexported keys and exported accessors; fail boot on misconfiguration. - Verifier real / minting test-only split for security-sensitive guards.
- Framework never imports the app; PHP ownership decides which plugin registers a middleware or guard.
Integration Points
surf.Assemble— CORS becomes path-scoped, limiter becomes real, raw-group handling and the route table are added here.bonfirekernel —route:listcommand.golem15.fonotekaplugin.go/routes — group builders, guard and bucket registration, genres under the token prefix.compassconfig —http.trusted_proxies,http.cors.*,http.fetch.*, body limits.
</code_context>
## Specific Ideas- Limits and keys are contract, not tuning: the
fonoteka-oauth-*30/min-per-IP values are deliberately generous because DCR traffic comes from vendor egress IPs. - Route files should still read like
routes.php, includingthrottle:10,1andinv.scope:writestrings. - Go may be safer than PHP where the wire contract cannot tell (dial-time SSRF check, trusted-proxy IP), never looser.
- OAuth exemption must be enforced by the router and provable from the route table, not upheld by convention.
user-api,pin-login,2fa-verifybuckets — Phase 7 with the user plugin; feedback and websockets buckets with their plugin ports.- Shared/Postgres-backed limiter store for multi-replica deployments — after v1; the Store interface keeps it possible.
coolercache package on otter — when a phase actually needs an in-process cache.ManualCoverUrlFetcherandCoverImporterports with MIME handling — Phase 12/14.- OAuth bearer guard and OAuth handlers on the raw group — Phase 8.
- Token CRUD and production minting — Phase 7.
Phase: 06-http-routing-auth-groups-and-rate-limiting Context gathered: 2026-09-19