Files
summercms/.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-CONTEXT.md
2026-09-19 14:04:36 +02:00

15 KiB
Raw Blame History

Phase 6: HTTP routing, auth groups and rate limiting - Context

Gathered: 2026-09-19 Status: Ready for planning

## Phase Boundary

Phase 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 Decisions

Rate-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, key tok:<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 inline throttle:N,M mechanism (PHP uses 10,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 of golem15.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 ThrottleRequests wire parity: fixed window per key (first hit opens the window), X-RateLimit-Limit and X-RateLimit-Remaining on limited responses, Retry-After and X-RateLimit-Reset on 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. No otter, no cooler package this phase.
  • D-04: Client IP comes from a trusted-proxy rule: a http.trusted_proxies CIDR list; X-Forwarded-For is honored only when RemoteAddr is inside that list, taking the rightmost untrusted hop; empty list means RemoteAddr only. One framework function is the single source of client IP for limiter keys (and later logging).
  • D-05: Parameterized middleware names are a surf feature: throttle:10,1, throttle:fonoteka-public-ip and inv.scope:write are 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: bouncer gets a named guard registry. Plugins register at Register time under a name (jwt, inv_token; oauth later) a Guard with an authenticate-request → principal shape. Auth middleware is derived from a guard by name. Every guard writes the same bouncer.User(ctx) accessor, plus an optional credential accessor (the resolved token) that inv.scope and the tok:<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 ApiToken model 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 the golem15.fonoteka plugin, 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>"}. The inv_token guard is registered by golem15.fonoteka too, mirroring PHP ownership.
  • D-09: The oauth guard 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 registers oauth.
  • D-10: The Phase 3 JWT verifier, its 401 bodies and the 423 gate are kept unchanged; jwt.auth is re-expressed as the jwt guard 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, so evil-discogs.com never passes) and PublicOnly (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.Dialer Control 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 httptest servers), with typed failure reasons that map onto PHP's invalid_url / unresolvable / private_ip / network_error / too_large family. ManualCoverUrlFetcher and CoverImporter ports 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.go as group builders with their exact middleware stacks, but only routes with real handlers are mounted. The shared-handler proof is GET genres under both /_fonoteka/api/v1 (JWT) and /api/v1/fonoteka (inv.scope:read), which flips GET__api_v1_fonoteka_genres_personal_token to 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 stay pending until 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:list command; success criterion 3's "verified by route-registration inspection" is a test over that table. The /.well-known/oauth-authorization-server and /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:00 time, 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 reproduces config/cors.php exactly — including that _fonoteka/api/* is not listed, so the JWT group gets no CORS headers, as today. JSON body limits use http.MaxBytesReader per 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,M key composition, as long as it matches Laravel's (D-02 research).
  • swag workflow: which repo holds annotations and generated spec, the summer command or script that runs swag and openapi-typescript, and a drift check. Default: annotations on the real fonoteka handlers, generated spec committed in fonoteka.go, types validity checked in the phase gate script.
  • Locale stage depth beyond Phase 3's Accept-Language read, 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 five RateLimiter::for buckets 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 inline throttle: 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.php around lines 121 and 262, and /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/auth/ — the inv_token guard 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.php and classes/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/noopLimiter seam, 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; note cooler/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.md D-07, D-09, D-10, D-12, D-14, D-15 — what Phase 3 left as seams for this phase.
  • .planning/REQUIREMENTS.md HTTP-03–HTTP-09; .planning/ROADMAP.md Phase 6; CLAUDE.md GSD lean-mode rules.

</canonical_refs>

<code_context>

Existing Code Insights

Reusable Assets

  • surf.Router/Group with Use, Where, WhereIn, fail-boot on unknown middleware names; only Get exists today — other verbs are needed for later phases but only as far as mounted routes require.
  • surf.Limiter interface with noopLimiter in 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, OAuthRefreshToken models (Phase 5).
  • Parity seedHooks seam 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.Context with 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.
  • bonfire kernel — route:list command.
  • golem15.fonoteka plugin.go/routes — group builders, guard and bucket registration, genres under the token prefix.
  • compass config — 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, including throttle:10,1 and inv.scope:write strings.
  • 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.
## Deferred Ideas
  • user-api, pin-login, 2fa-verify buckets — 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.
  • cooler cache package on otter — when a phase actually needs an in-process cache.
  • ManualCoverUrlFetcher and CoverImporter ports 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