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

139 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Phase 6: HTTP routing, auth groups and rate limiting - Context
**Gathered:** 2026-09-19
**Status:** Ready for planning
<domain>
## 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).
</domain>
<decisions>
## 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.
</decisions>
<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>
<specifics>
## 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.
</specifics>
<deferred>
## 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.
</deferred>
---
*Phase: 06-http-routing-auth-groups-and-rate-limiting*
*Context gathered: 2026-09-19*