139 lines
15 KiB
Markdown
139 lines
15 KiB
Markdown
# 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*
|