diff --git a/.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-RESEARCH.md b/.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-RESEARCH.md
new file mode 100644
index 0000000..facb130
--- /dev/null
+++ b/.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-RESEARCH.md
@@ -0,0 +1,595 @@
+# Phase 6: HTTP routing, auth groups and rate limiting - Research
+
+**Researched:** 2026-09-19
+**Domain:** HTTP routing/middleware, auth guard registry, rate limiting, SSRF-guarded outbound fetch, OpenAPI generation (Go 1.27, net/http ServeMux)
+**Confidence:** HIGH on PHP contract evidence (read directly from source/vendor), MEDIUM on Go stdlib SSRF/limiter patterns (well-established but not project-specific yet), LOW on production body-size limits (not found in repo — flagged)
+
+
+## User Constraints (from CONTEXT.md)
+
+### Locked 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:` else client IP), `fonoteka-oauth-token` (30/min, `oauthtok:`), `fonoteka-oauth-register` (30/min, `oauthreg:`), `fonoteka-public-token` (60/min, `pubtok:`), `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:` 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:` 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: "}`. 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.
+
+### Deferred Ideas (OUT OF SCOPE)
+- `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.
+
+Not in this phase (from Phase Boundary): 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).
+
+
+
+## Phase Requirements
+
+| ID | Description | Research Support |
+|----|-------------|------------------|
+| HTTP-03 | Three mutually exclusive auth groups share the same handlers with different route subsets: JWT under /_fonoteka/api/v1, personal scoped token under /api/v1/fonoteka, and public groups (onboarding, public/{token}, public-wishlist/{token}, invitation inspection) | `routes.php` read in full (all 7 groups); parity manifest confirms 154-route `auth_group` split (jwt 105, personal_token 34, oauth 4, onboarding 2, public_share 6, public_invitation 1, jwt_locale 2); genres fixture pair proves the shared-handler shape; Architecture Pattern 1 (guard registry) and Pattern 3 (raw group) |
+| HTTP-04 | A rate limiter supports named buckets keyed by a resolver (token id, IP, route param), stacking two limiters on one route, and ports Płytarium's named buckets and inline throttles 1:1 | Laravel `RateLimiter`/`ThrottleRequests` vendor source read directly (Common Pitfalls #1–#5); five buckets + inline throttle values enumerated in User Constraints D-01; stacking example (`fonoteka-public-token` + `fonoteka-public-ip`) read from `routes.php` line 410 |
+| HTTP-05 | An auth guard registry lets plugins add guards (JWT, personal token, OAuth bearer) that all resolve to the same current-user accessor | Architecture Pattern 1; `ApiTokenGuard.php`/`ApiTokenManager.php` read in full for D-07's verify/hash/expiry/revocation/last-used rules; existing `bouncer/jwt.go`/`context.go` inventoried as the seam to extend |
+| HTTP-06 | Response conventions are preserved: empty arrays serialize as [], timestamps as +00:00, tri-state booleans keep null, conditional keys are omitted not nulled, and no blanket envelope or error middleware wraps OAuth routes | `SerializesFonoteka.php` read in full; PITFALLS.md Pitfalls 4/6/7/8/9 cross-checked against source; Architecture Pattern 3 (raw group) and the `.well-known` fixture proving the unenveloped OAuth body |
+| HTTP-07 | A guarded outbound fetch helper enforces host allow-lists, byte caps and timeouts for user-supplied URLs (manual cover URL, Discogs cover) | `ManualCoverUrlFetcher.php` and `CoverImporter.php` read in full; Common Pitfalls #6–#8; Code Examples (dial-time SSRF guard); `net/netip` capability verified via `go doc` this session |
+| HTTP-08 | OpenAPI is generated from swaggo/swag annotations on handlers and openapi-typescript produces the admin SPA's types | Standard Stack (versions re-verified this session via `go list -m` / `npm view`); Package Legitimacy Audit |
+| HTTP-09 | CORS and JSON body size limits match the PHP deployment | `config/cors.php` read in full; genres personal-token fixture confirms wildcard CORS on `/api/v1/fonoteka/*`; Common Pitfalls #10; body-size values NOT found in repo — flagged as Assumption A2 / Open Question 2, requires `checkpoint:human-verify` |
+
+
+## Project Constraints (from CLAUDE.md)
+
+Extracted from the repo-root, `summercms.go/`, and user-global `CLAUDE.md` files. The planner must not produce tasks that contradict these:
+
+- **Stdlib first.** Standard library first (`net/http` ServeMux, `html/template`, `encoding/json`); add a dependency only when a research doc or phase decision names it. This phase names exactly one new dependency: `swaggo/swag` (already STACK-named). The limiter, guard registry, and SSRF fetch helper must be stdlib-only per CONTEXT D-03/D-11/D-12.
+- **Lean planning.** Prefer fewer, larger plans per phase; skip optional agents unless the phase touches security or the plugin API — **this phase does both** (security-load-bearing per ROADMAP: auth guard registry, rate limiting, SSRF fetch helper), so the security-review agent is required, not optional.
+- **Checkpoint on plan count.** Present the suggested number of plans with a one-line scope each and wait for confirmation before writing PLAN.md files.
+- **Unit tests are always the last plan of a phase.** Earlier plans may include smoke tests but must not be blocked on full coverage.
+- **`go vet` and `go test ./...` green at every commit.**
+- **Compiled plugins only.** No runtime plugin loading (stdlib `plugin`, yaegi) — not directly implicated this phase, but the guard/middleware registries must remain compile-time registration (`init()`-time `Register()` calls), matching the existing `RegisterMiddleware`/party.Register pattern.
+- **API parity is the acceptance test.** Do not "improve" response shapes during the port — directly relevant to the two divergent 429 body shapes (Common Pitfalls #2) and the CORS asymmetry (Common Pitfalls #10): both must be ported as-is, not unified into "one consistent" shape.
+- **Core plugin contracts (user, blog, pages, payment) must not have breaking changes** unless directly asked. The `jwt` guard's behavior (D-10) is explicitly "kept unchanged" — this phase re-expresses it in the registry without altering its 401 bodies or the 423 gate.
+- **Commit rules:** never add co-author tags; one logical change per commit; planning docs and code in separate commits.
+- **Two-repository boundary:** `summercms.go` (framework) must know nothing about Płytarium; `fonoteka.go` (app) holds the `inv_token` guard, `inv.scope`, the five fonoteka buckets, and the group builders. Confirmed against the existing code split (`bouncer`/`surf` in `summercms.go` vs. `plugins/golem15/fonoteka` in `fonoteka.go`).
+
+## Summary
+
+Phase 6 turns the Phase 3 routing skeleton (`surf.Router`, GET-only, `noopLimiter`, `bouncer.Middleware` as a bare `func(http.Handler) http.Handler`) into the full HTTP contract layer PHP's `routes.php` describes: three mutually exclusive auth groups sharing handlers, five real named rate-limit buckets plus inline throttles with fixed-window Laravel-compatible semantics, a named guard registry in `bouncer` with two real guards (`jwt`, `inv_token`), structural OAuth-group exemption from house envelope/error middleware, response-convention helpers, an SSRF-guarded outbound fetch helper checked at dial time, path-scoped CORS, and a swaggo/swag → openapi-typescript pipeline. Every PHP source file named in CONTEXT.md's canonical refs was read directly (not summarized from memory): `routes.php` (all 568 lines), `TokenScope.php`, `PublicShareHeaders.php`, `ApiTokenGuard.php`/`ApiTokenManager.php`, `ManualCoverUrlFetcher.php`, `CoverImporter.php`, `config/cors.php`, `config/cache.php`, `JwtAuthenticate.php`, `SerializesFonoteka.php`, plus the actual installed `laravel/framework` (9.x-dev, locked commit `feb47bae`) vendor source for `ThrottleRequests.php`, `RateLimiter.php`, `ThrottleRequestsException.php`, and Winter's `Foundation/Exception/Handler.php` plus Laravel's own `Illuminate/Foundation/Exceptions/Handler.php` to get the exact 429 wire body.
+
+**Primary recommendation:** Build the guard registry, limiter, and SSRF fetch helper as framework (`bouncer`/`surf`) primitives with a `Store`/`Policy` seam per CONTEXT D-03/D-11, keep every bucket name, key format and limit number as a literal port (not a redesign), add `Post`/`Put`/`Patch`/`Delete` to `pact.Router`/`surf.Router` (currently GET-only — this is a required seam growth, not optional), and gate the two unresolved deployment facts (production `client_max_body_size`/`post_max_size`, and whether `APP_DEBUG` truly is `false` on the box the fixtures were recorded from) behind `checkpoint:human-verify` rather than guessing.
+
+## Architectural Responsibility Map
+
+| Capability | Primary Tier | Secondary Tier | Rationale |
+|------------|-------------|----------------|-----------|
+| Auth group routing (JWT / personal-token / public) | API/Backend (`surf` framework) | — | Route-group middleware stacks are a backend routing concern; PHP does this in `routes.php` at the framework/routing layer, not in controllers |
+| Guard registry + guard resolution | API/Backend (`bouncer` framework) | — | Credential verification is framework-owned; app plugins register guards by name (`golem15.fonoteka` registers `inv_token`) without the framework knowing about tokens |
+| `inv.scope` scope gate | API/Backend (`golem15.fonoteka` app plugin) | — | PHP owns `TokenScope` in the fonoteka plugin, not a core middleware — matches PHP ownership |
+| Rate limiter (buckets, Store) | API/Backend (`surf` framework: mechanism) + app plugin (bucket definitions) | — | Framework ships the `Limiter`/`Store` seam (like `RateLimiter::for` machinery); each plugin registers its own named buckets, exactly as `fonoteka/routes.php` calls `RateLimiter::for()` |
+| SSRF-guarded fetch helper | API/Backend (`surf` or new framework package) | — | Outbound network calls triggered by a user-supplied URL are a backend concern; PHP's `ManualCoverUrlFetcher`/`CoverImporter` live in the app plugin but the *mechanism* (dial-time IP check) is generic enough to be framework-owned per CONTEXT discretion |
+| Response convention helpers (JSON writer, time/bool/slice types) | API/Backend (`surf` or new `parchment`-style framework package) | — | Wire-format helpers, not app logic; PHP's equivalent (`SerializesFonoteka` conventions) is per-plugin but the *primitives* (Carbon-format time, never-nil slice) are framework-shaped |
+| CORS | API/Backend (`surf` framework, path-scoped) | CDN/Static (not in scope — no CDN layer exists) | Laravel's CORS middleware runs in the HTTP kernel; the Go equivalent is framework middleware, config-driven per plugin path |
+| OpenAPI generation | API/Backend (build-time tool, swaggo/swag) | Frontend Server (openapi-typescript consumes it for the Vue SPA, Phase 10) | Annotations live on the real handlers (backend); the generated types are consumed by a different tier later |
+| Body-size limits | API/Backend (`http.MaxBytesReader` per group) | CDN/Static (nginx `client_max_body_size` sits in front — outside this repo) | The Go app must enforce its own ceiling regardless of what nginx does in front of it, since dev/test never runs behind that nginx |
+
+## Standard Stack
+
+### Core
+| Library | Version | Purpose | Why Standard |
+|---------|---------|---------|--------------|
+| net/http (stdlib) | Go 1.27.0 | Routing, `ServeMux`, `Dialer.Control` for SSRF | CLAUDE.md stdlib-first constraint; project has no router dependency and none is needed for the shapes this phase adds |
+| net/netip (stdlib) | Go 1.27.0 | IP classification for the SSRF guard (`Addr.Is4In6`, `Addr.Unmap`, `Addr.IsPrivate/IsLoopback/IsLinkLocalUnicast/IsMulticast/IsUnspecified`) | Modern replacement for `net.IP` predicate methods; `net.IP.IsPrivate()`'s own doc comment states it "does not describe a security property... should not be used for access control" — `netip.Addr` plus an explicit CIDR table (below) is the documented-safe pattern [VERIFIED: go doc, local Go 1.27 toolchain] |
+| crypto/subtle (stdlib) | Go 1.27.0 | Constant-time comparisons anywhere a secret/hash is compared (token hash lookup path, later OAuth phases) | PITFALLS.md "Constant-time secret comparison lost in translation" names this explicitly; the token guard's `hash('sha256', ...)` DB lookup itself is fine (indexed equality, not attacker-timed), but any raw bearer-vs-constant compare must use this |
+| github.com/swaggo/swag | v1.16.6 (confirmed resolvable: `go list -m github.com/swaggo/swag@v1.16.6` → `v1.16.6`) [VERIFIED: pkg.go.dev / go module proxy, this session] | Generate OpenAPI from `net/http`-handler comment annotations | STACK.md-named, already decided; annotations require no handler signature change, matching the plugin model |
+| openapi-typescript | npm, current release 7.13.0 [VERIFIED: `npm view openapi-typescript version`, this session] | Generate the Vue admin SPA's TS types from swag's output | STACK.md-named; official repo `github.com/openapi-ts/openapi-typescript`, package created 2020, actively maintained |
+
+### Supporting
+| Library | Version | Purpose | When to Use |
+|---------|---------|---------|-------------|
+| stdlib `sync.Map` or `sync.Mutex`+`map` | Go 1.27.0 | In-process rate-limit counter store (CONTEXT D-03: mutex-guarded map with expiry sweep) | Behind `surf.Limiter`'s `Store` interface; no third-party cache library this phase (`otter`/`cooler` explicitly deferred) |
+| stdlib `time.AfterFunc` or a ticker goroutine | Go 1.27.0 | Expiry sweep for the in-process counter store | Keep the sweep interval a `Claude's Discretion` config value, not hardcoded |
+
+### Alternatives Considered
+| Instead of | Could Use | Tradeoff |
+|------------|-----------|----------|
+| stdlib mutex-guarded map limiter | `golang.org/x/time/rate` (token bucket) | Wrong algorithm shape: PHP's `RateLimiter` is a **fixed window** (see Common Pitfalls below), not a token bucket — `x/time/rate` would silently change the smoothing behavior at the bucket boundary and is not what D-02 asks the researcher to confirm parity against. Do not use for the named-bucket parity requirement. |
+| stdlib mutex-guarded map limiter | `otter`/`cooler` (project's own future cache package) | Explicitly deferred by CONTEXT D-03 and the Deferred Ideas section — "when a phase actually needs an in-process cache," not this one |
+| swaggo/swag (code-first) | Huma + humago adapter | Requires handlers to be Huma-shaped (`func(ctx, *Input) (*Output, error)`), not `http.HandlerFunc` — rejected in STACK.md specifically because the whole point of v1 is byte-compatible parity with handlers that already exist in an ordinary shape |
+| net/netip IP classification | `net.IP` predicate methods only | `net.IP.IsPrivate()` explicitly disclaims security use in its own doc comment (see Standard Stack); CGNAT (100.64.0.0/10) has no stdlib predicate at all in either package — must be a manual CIDR check regardless of which IP type is chosen |
+
+**Installation:**
+```bash
+# Framework repo (summercms.go) — swag CLI is a build-time tool, not a go.mod dependency
+# unless the annotation-parsing package (github.com/swaggo/swag) is imported directly by
+# a `summer openapi:generate` command. Confirm at plan time whether swag is invoked as an
+# external `go run github.com/swaggo/swag/cmd/swag@v1.16.6` (no go.mod entry needed) or
+# wired as a package import (needs `go get github.com/swaggo/swag@v1.16.6`).
+go get github.com/swaggo/swag@v1.16.6 # only if importing the package, not just the CLI
+
+# fonoteka.go (Node/npm side, vue-fonoteka-app or a dedicated codegen script)
+npm install --save-dev openapi-typescript@^7.13.0
+```
+
+**Version verification:** `go list -m github.com/swaggo/swag@v1.16.6` resolved cleanly this session (module proxy). `swag --version` on a locally `go install`-ed binary printed `v1.16.4`, one patch behind the tag requested — this is a known quirk of swag's embedded version string lagging its git tag in some release builds, not evidence the v1.16.6 module is wrong; the **module** resolution (`go list -m`) is the authoritative check, not the CLI's self-reported string. `npm view openapi-typescript version` returned `7.13.0` this session, matching STACK.md's "current npm release" note.
+
+## Package Legitimacy Audit
+
+| Package | Registry | Age | Downloads | Source Repo | slopcheck | Disposition |
+|---------|----------|-----|-----------|-------------|-----------|-------------|
+| github.com/swaggo/swag | go | long-established (12.9k GitHub stars per STACK.md) | high (standard Go OpenAPI tool) | github.com/swaggo/swag | `[OK]` (slopcheck ran this session; noted "No source repository linked" in its own output, which is a **slopcheck metadata gap**, not a real finding — the repo does exist at github.com/swaggo/swag and is the one STACK.md already named) | Approved |
+| openapi-typescript | npm | created 2020-10-17 [VERIFIED: `npm view openapi-typescript time.created`] | high (widely used OpenAPI→TS generator) | github.com/openapi-ts/openapi-typescript [VERIFIED: `npm view openapi-typescript repository.url`] | not run (slopcheck auto-detected `go` ecosystem in this Go-repo working directory and would have force-installed into the wrong registry; ran manual npm registry verification instead, see Standard Stack) | Approved |
+
+**Packages removed due to slopcheck [SLOP] verdict:** none.
+**Packages flagged as suspicious [SUS]:** none — both packages were already named in STACK.md (a prior, human-reviewed research pass); this session only re-verified currency, not novelty.
+
+No other new third-party packages are introduced this phase. The limiter, guard registry, SSRF guard and response helpers are all stdlib-only per CLAUDE.md and CONTEXT D-03.
+
+## Architecture Patterns
+
+### System Architecture Diagram
+
+```
+ ┌──────────────────────────────────────────────┐
+ │ surf.Assemble (per-request) │
+ │ │
+ Nuxt / MCP / curl │ recoverJSON → cors(path-scoped) → locale │
+ request │ │ │
+ │ │ ▼ │
+ │ │ route match (net/http ServeMux, 1.22+ │
+ │ │ {param} patterns) + Where/WhereIn │
+ │ │ constraint check → 404 on miss/malformed │
+ │ │ │ │
+ │ │ ▼ │
+ │ │ ┌─────────────── group middleware chain ──┐ │
+ │ │ │ raw-group check (OAuth/RFC: SKIP the │ │
+ │ │ │ next 3 stages entirely if group.Raw) │ │
+ │ │ │ │ │ │
+ │ │ │ ▼ │ │
+ │ │ │ bouncer: guard-by-name resolution │ │
+ │ │ │ "jwt" → JWT verify → Principal │ │
+ │ │ │ "inv_token" → ApiToken verify+scope │ │
+ │ │ │ (none) → public/onboarding group │ │
+ │ │ │ │ │ │
+ │ │ │ ▼ │ │
+ │ │ │ inv.must-change-password (JWT only) │ │
+ │ │ │ │ │ │
+ │ │ │ ▼ │ │
+ │ │ │ org/collection context slot │ │
+ │ │ │ │ │ │
+ │ │ │ ▼ │ │
+ │ │ │ surf.Limiter.Wrap (named bucket(s), │ │
+ │ │ │ possibly stacked: e.g. │ │
+ │ │ │ fonoteka-public-token + -public-ip) │ │
+ │ │ └────────┬──────────────────────────────┘ │
+ │ │ ▼ │
+ │ │ plugin handler (controllers.*) │
+ │ │ │ │
+ │ │ ┌────────┴─────────┐ │
+ │ │ ▼ ▼ │
+ │ │ lagoon (GORM/PG) fetch helper (SSRF-guard) │
+ │ │ for cover_url/Discogs │
+ │ │ │ │
+ │ │ ▼ │
+ │ │ net.Dialer.Control │
+ │ │ (dial-time IP re-check, │
+ │ │ no redirects, byte cap) │
+ │ └──────────────────────────────────────────────┘
+ │ │
+ ▼ ▼
+ house JSON (data/meta envelope, RFC 6749/8414 bare JSON
+ never-nil [], +00:00 times) (OAuth group: raw, no
+ envelope/error middleware)
+```
+
+### Recommended Project Structure
+```
+summercms.go/
+├── bouncer/
+│ ├── jwt.go # existing: Verify(), Middleware() — becomes the "jwt" guard body
+│ ├── context.go # existing: Principal, WithUser/User
+│ ├── registry.go # NEW: named Guard registry (D-06)
+│ └── guard.go # NEW: Guard interface (authenticate-request → principal + optional credential accessor)
+├── surf/
+│ ├── router.go # existing: extend with Post/Put/Patch/Delete, raw-group flag, route table
+│ ├── params.go # existing: Constraint/IntParam — unchanged
+│ ├── limiter.go # NEW: real Limiter behind the existing interface, named-bucket resolution
+│ ├── limiter_store.go # NEW: Store interface + in-process mutex-map implementation (D-03)
+│ ├── clientip.go # NEW: trusted-proxy-aware client IP resolver (D-04)
+│ ├── cors.go # extend existing cors() to be path-scoped/config-driven (D-18)
+│ └── routetable.go # NEW: exported route table (method, pattern, plugin, middleware, raw) for tests + route:list (D-16)
+├── (new package, e.g. "fetchguard" or under surf)
+│ └── fetch.go # NEW: SSRF-guarded outbound fetch helper, Policy{AllowHosts|PublicOnly} (D-11–D-14)
+└── (new package, e.g. "wire" or "parchment")
+ └── response.go # NEW: JSON writer + Time/Bool/Slice response-convention types (D-17)
+
+fonoteka.go/
+├── plugins/golem15/fonoteka/
+│ ├── classes/auth/
+│ │ ├── token_guard.go # NEW: inv_token Guard implementation reading models.ApiToken (D-07)
+│ │ └── token_scope.go # NEW: inv.scope: middleware, ported from TokenScope.php (D-08)
+│ ├── routes.go # extend: group builders for all 7 PHP route groups (D-15)
+│ └── plugin.go # extend: register "inv_token" guard + 5 named buckets at Boot
+```
+
+### Pattern 1: Named guard registry, one accessor
+**What:** `bouncer.RegisterGuard(pluginID, name string, g Guard) error` at Register/Boot time; `bouncer.Middleware(name string) (func(http.Handler) http.Handler, error)` derives auth middleware from a registered guard by name for `surf.Use("jwt")` / `surf.Use("inv_token")` call sites. Every guard writes the same `bouncer.User(ctx)` accessor (existing `Principal`), plus an optional `Credential(ctx)` accessor for the resolved token (needed for the `tok:` limiter key and `inv.scope`).
+**When to use:** Any route group needing authentication. Duplicate or unknown guard names fail boot (mirrors `RegisterMiddleware`'s existing duplicate-name failure pattern in `surf/router.go`).
+**Example (framework side, mirroring the existing `RegisterMiddleware` shape in `surf/router.go`):**
+```go
+// Source: pattern derived from surf.Router.RegisterMiddleware (surf/router.go:68-80),
+// applied to bouncer per CONTEXT D-06. Not copied from an external doc — this
+// project's own established idiom for named-registration-fails-on-duplicate.
+type Guard interface {
+ Authenticate(r *http.Request) (*Principal, error) // nil, err on failure
+}
+
+type CredentialGuard interface {
+ Guard
+ Credential(r *http.Request) (any, bool) // e.g. *ApiToken for inv_token
+}
+
+func (reg *Registry) Register(pluginID, name string, g Guard) error {
+ if name == "" || g == nil {
+ return fmt.Errorf("bouncer: plugin %q registered empty guard", pluginID)
+ }
+ if existing, ok := reg.guards[name]; ok {
+ return fmt.Errorf("bouncer: guard %q already registered by %s", name, existing.pluginID)
+ }
+ reg.guards[name] = namedGuard{pluginID: pluginID, guard: g}
+ return nil
+}
+```
+
+### Pattern 2: PHP `TokenScope` ported as `inv.scope:` middleware
+**What:** The scope gate is a two-job middleware (deny-by-default 401/403, then bind the resolved user into the request so downstream handlers see the token's user) — read verbatim from `TokenScope.php`.
+**When to use:** Every route in the `/api/v1/fonoteka` group.
+**Example:**
+```go
+// Source: ported 1:1 from
+// /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/middleware/TokenScope.php
+// Exact bodies per D-08 / PHP lines 44-51.
+func InvScope(scope string) pact.Middleware {
+ return func(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ principal, ok := bouncer.User(r.Context()) // resolved by the "inv_token" guard upstream
+ if !ok || principal == nil {
+ writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "Invalid token"})
+ return
+ }
+ tok, _ := bouncer.Credential(r.Context()) // *models.ApiToken
+ token, ok := tok.(*models.ApiToken)
+ if !ok || !token.HasScope(scope) {
+ writeJSON(w, http.StatusForbidden, map[string]string{"error": "Missing required scope: " + scope})
+ return
+ }
+ next.ServeHTTP(w, r)
+ })
+ }
+}
+```
+Note the PHP body keys are `error` (not `message`), matching `TokenScope.php` lines 45 and 50 exactly — do not reuse the house `{"error":true,"message":...}` shape from `bouncer/jwt.go`'s `write401`, which is a *different* wire shape (`error` is a bool there, a string here). This is a real divergence in the PHP contract between the JWT guard's 401 body and the token guard's 401/403 bodies — verified by reading both `JwtAuthenticate.php` (`{"error": true, "message": ...}`) and `TokenScope.php` (`{"error": "Invalid token"}`) side by side.
+
+### Pattern 3: Raw group structural exemption (D-16)
+**What:** A group-level `Raw bool` flag that (a) refuses registration of any middleware tagged house-envelope/error at `Group()` call time — fail boot, not silent skip — and (b) uses a bare-500 recover instead of `recoverJSON`.
+**When to use:** `/.well-known/oauth-authorization-server` and `/oauth/mcp/*` only, this phase (handlers arrive Phase 8; the group and its two throttles — `fonoteka-oauth-token`, `fonoteka-oauth-register` — are declared now per D-16).
+**Example (test-proof shape, since D-15 allows proving raw-group behavior on framework fixture routes when no real handler is cheap to port yet):**
+```go
+// Source: pattern for the route table CONTEXT D-16 requires ("route:list" +
+// test assertion), not copied from routes.php (which has no such introspection —
+// this is new Go-side tooling built to prove PHP's structural guarantee).
+type RouteInfo struct {
+ Method string
+ Pattern string
+ PluginID string
+ Middleware []string
+ Raw bool
+}
+
+func (r *Router) Routes() []RouteInfo { /* walk r.routes, expose read-only snapshot */ }
+```
+A test then asserts: for every `RouteInfo` where `Raw == true`, `Middleware` contains none of the house-envelope/error middleware names — this is "verified by route-registration inspection" per success criterion 3, not a behavioral HTTP test alone.
+
+### Anti-Patterns to Avoid
+- **One generic `APIResponse[T]` wrapper for all endpoints:** PITFALLS.md Pitfall 8 explicitly calls this out as a smell to catch in code review — the house REST envelope, the OAuth/RFC bare bodies, and per-field conditional-key-omission (e.g. `reservation` on `serializeAlbum`) cannot share one generic type.
+- **A blanket error-formatting middleware applied ahead of route dispatch:** would corrupt OAuth/RFC bodies (Pitfall 9) — the exemption must be structural (raw group flag checked at `Group()`/`Assemble()` time), not a per-handler `if` that a future PR can forget.
+- **Token-bucket rate limiting (`x/time/rate`) standing in for PHP's fixed-window `RateLimiter`:** different algorithm, will not reproduce the exact `X-RateLimit-Remaining` sequence PHP's `RateLimiter::hit()`/`tooManyAttempts()` produces (see Common Pitfalls).
+- **Resolving the client IP from `RemoteAddr` unconditionally, or from `X-Forwarded-For` unconditionally:** CONTEXT D-04 requires a trusted-proxy CIDR gate; unconditionally trusting `X-Forwarded-For` lets any client spoof their rate-limit key and bypass `fonoteka-public-ip`.
+
+## Don't Hand-Roll
+
+| Problem | Don't Build | Use Instead | Why |
+|---------|-------------|-------------|-----|
+| OpenAPI document generation | A hand-written OpenAPI YAML/JSON kept in sync manually | swaggo/swag scanning comment annotations | STACK.md already ruled this out at 154-route scale; comment-drift is real but cheaper than double-authoring |
+| IP-in-CIDR checks for private/CGNAT ranges | A hand-rolled bit-mask CIDR matcher (PHP's own `ipv4InCidr`/`ipv6HasPrefix` helper functions, which the PHP source itself had to write because PHP has no `netip`-equivalent) | `net/netip.Prefix.Contains(netip.Addr)` (stdlib) | Go's stdlib already has this; PHP needed to hand-roll it (see `ManualCoverUrlFetcher::ipv4InCidr`) only because PHP lacks the equivalent. Do not port PHP's hand-rolled CIDR math into Go — use `netip.MustParsePrefix(...).Contains(...)` |
+| TS types for the OpenAPI doc | A hand-maintained `.d.ts` file for the admin SPA | openapi-typescript generating from swag's output | Requirement HTTP-08 explicitly wants generated types, "no hand-maintained duplicate type" per Phase 10 success criteria |
+
+**Key insight:** The PHP source frequently hand-rolls things (CIDR matching, exception-to-JSON normalization in `PublicShareHeaders`) that Go's stdlib or this project's existing tooling already solves — the port should recognize "PHP had to write this because PHP lacks X" and use Go's X, not transliterate the hand-rolled PHP code.
+
+## Common Pitfalls
+
+### Pitfall 1: Fixed-window rate limiting mistaken for a token bucket
+**What goes wrong:** `Illuminate\Cache\RateLimiter::hit()` (read directly, `vendor/laravel/framework/.../Cache/RateLimiter.php`) is a classic **fixed window**: the first `hit()` for a key sets a `:timer` sentinel at `now + decaySeconds` via `cache->add()` (only-if-absent) and a separate counter key, also `add()`-then-`increment()`. `tooManyAttempts()` checks `attempts(key) >= maxAttempts` AND that the `:timer` key still exists; if the counter is at/over the limit but the timer has expired, it silently calls `resetAttempts()` and allows the request (this is the fixed-window "burst at window boundary" behavior — a client can send `maxAttempts` requests at `t=0.99s` and another `maxAttempts` at `t=1.01s`). A sliding-window or token-bucket Go port would produce a *different* sequence of 429s than PHP does at the boundary.
+**Why it happens:** "Rate limiter" reads as one universal concept; Laravel's specific fixed-window-with-add-then-increment mechanics are easy to abstract away during a port.
+**How to avoid:** Implement the in-process `Store` (D-03) with the same two-key shape: a hit counter with TTL = `decaySeconds`, first-hit-wins semantics (`add` not `set`). Port `tooManyAttempts`/`hit`/`availableIn` as named methods with the same control flow, not a redesigned algorithm.
+**Warning signs:** A limiter implementation using a sliding log, leaky bucket, or `x/time/rate.Limiter` for named-bucket parity.
+**Phase to address:** This phase (D-02's explicit ask).
+
+### Pitfall 2: Two different 429 wire bodies depending on route group — and a possible `APP_DEBUG` trap
+**What goes wrong:** Reading the full render chain (`ThrottleRequestsException` → `TooManyRequestsHttpException` → Winter's `Foundation/Exception/Handler::render()` → `parent::render()` → Laravel's `Illuminate/Foundation/Exceptions/Handler::renderExceptionResponse()` → `shouldReturnJson()` (`$request->expectsJson()`) → `prepareJsonResponse()` → `convertExceptionToArray()`) shows: for a JSON-expecting request hitting the **JWT or personal-token** group's inline `throttle:N,M`, the body is `{"message": "Too Many Attempts."}` at HTTP 429 **only if `config('app.debug')` is false** — if `app.debug` is true, the body instead includes `exception`, `file`, `line`, `trace` keys (a stack trace leak). The **public-share group** is different again: `PublicShareHeaders.php` explicitly catches `ThrottleRequestsException` and rewrites it to `{"error": "Too many requests"}` (note: `error` key, different string, no trailing period) — this middleware exists *specifically* because Winter's default HTML-error-page rendering would otherwise leak a stack trace to an anonymous, unauthenticated caller (its own doc comment says so). Local dev `.env` has `APP_DEBUG=true`; the production runbook (`docs/deploy/plytarium.com.md`, D-16 carry-forward env) states `APP_DEBUG=false` is required in production ("Winter stack-traces public routes when true"). **Which value the parity fixtures were actually recorded against is not verified in this research pass** — flagged below.
+**Why it happens:** Laravel's exception-to-JSON path branches on both `expectsJson()` and `app.debug`, and a *third* group-specific override (`PublicShareHeaders`) exists precisely because of a documented Winter HTML-leak bug on this one surface — three different code paths producing three different 429 bodies is easy to collapse into "just return 429" during a port.
+**How to avoid:** Port three distinct 429 shapes: (1) house-default `{"message":"Too Many Attempts."}` for JWT/personal-token groups (assuming production `app.debug=false` — confirm against a real recorded fixture before finalizing, see Open Questions), (2) `{"error":"Too many requests"}` for the public-share group specifically, (3) headers (`X-RateLimit-Limit`, `X-RateLimit-Remaining` always; `Retry-After`, `X-RateLimit-Reset` only on the 429 itself) attached identically across all three, per `ThrottleRequests::getHeaders()`.
+**Warning signs:** A single shared "throttled" response function used across all route groups.
+**Phase to address:** This phase.
+
+### Pitfall 3: `X-RateLimit-*` headers are also added to *successful* (non-429) responses
+**What goes wrong:** `ThrottleRequests::handleRequest()` calls `addHeaders()` on the successful `$response` too (lines 128-134 of the vendor source), not just on the 429 path — every throttled route's 2xx response also carries `X-RateLimit-Limit`/`X-RateLimit-Remaining`. A Go port that only sets these headers in the 429 branch will diverge from every parity fixture for a throttled route's happy path.
+**How to avoid:** Set `X-RateLimit-Limit`/`X-RateLimit-Remaining` on every response that passed through a named/inline limiter, success or not; add `Retry-After`/`X-RateLimit-Reset` only when actually throttled.
+**Phase to address:** This phase.
+
+### Pitfall 4: Inline `throttle:N,M` key composition depends on `$request->user()` vs route+IP
+**What goes wrong:** `ThrottleRequests::resolveRequestSignature()` (vendor source, lines 167-176) computes the key as `sha1($user->getAuthIdentifier())` **if a user is resolved**, else `sha1($route->getDomain() . '|' . $request->ip())`. On the JWT group, `$request->user()` is set by `jwt.auth`, so every inline `throttle:10,1`/`throttle:20,1`/`throttle:60,1`/`throttle:12,1` call site in the JWT group keys by **user id**, not IP — meaning two different users sharing an IP get independent throttle budgets on those routes, but a not-yet-authenticated public-group inline `throttle:10,1` (onboarding, invitations) keys by **route domain + IP** since there's no user yet.
+**How to avoid:** The inline throttle key resolver must check for a resolved principal first (mirroring `bouncer.User(ctx)`), falling back to IP-based keying only when absent. This is a Claude's Discretion item (CONTEXT: "Inline `throttle:N,M` key composition, as long as it matches Laravel's D-02 research") — the match target is this exact `resolveRequestSignature` logic, not a guess.
+**Phase to address:** This phase.
+
+### Pitfall 5: The `fonoteka-api-token` bucket keys on the token, but falls back to IP *before* the guard resolves
+**What goes wrong:** `RateLimiter::for('fonoteka-api-token', ...)` in `routes.php` line 38 calls `app('auth')->guard('inv_token')->token()` — this triggers the guard's `user()` resolution as a side effect (see `ApiTokenGuard::token()`, which calls `$this->user()` first). The limiter itself runs via `throttle:fonoteka-api-token` attached at the **group** level (`routes.php` line 450), which in Laravel's middleware pipeline runs *after* route matching but the guard resolution inside the limiter closure is independent of (and happens before) `TokenScope`'s per-route `inv.scope:*` middleware. So the limiter can resolve a token/user even on a route that will later 401/403 in `TokenScope`. The Go port's middleware **ordering** must allow the limiter to call the same guard-resolution path independently, not assume `inv.scope` has already run and stashed a principal.
+**How to avoid:** Give the limiter's key-resolver function access to the raw request (to call the guard's own `Authenticate`/token-lookup independently) or run the `inv_token` guard resolution once, early, and cache it on the request context in a way readable both by the limiter and by `inv.scope` (avoiding a double DB verify). Confirm whichever pattern is chosen doesn't cause a double `last_used_at` stamp (see `ApiTokenGuard::user()`, which persists `last_used_at`/`last_used_ip` via `saveQuietly()` as a resolution side effect) — this write should happen at most once per request even if the guard is consulted twice.
+**Warning signs:** `last_used_at` updated more than once, or a second unnecessary DB round trip, per request on the token group.
+**Phase to address:** This phase.
+
+### Pitfall 6: `net.IP.IsPrivate()` alone is not the SSRF guard PHP has
+**What goes wrong:** `net.IP.IsPrivate()`'s own Go doc says "does not describe a security property... should not be used for access control." Neither `net.IP` nor `netip.Addr` has a built-in CGNAT (`100.64.0.0/10`) check, and neither has an "IPv4-mapped-IPv6 metadata address" normalization built in as a single call — `169.254.169.254` (cloud metadata) is link-local and *is* caught by `IsLinkLocalUnicast()`, but the same address reachable as `::ffff:169.254.169.254` needs `Addr.Unmap()` applied first or `IsLinkLocalUnicast()` on the unmapped form will not fire correctly on all platforms.
+**How to avoid:** Build the private/reserved check as an explicit table mirroring PHP's own `PRIVATE_V4_CIDRS`/`PRIVATE_V6_PREFIXES` constants (read directly from `ManualCoverUrlFetcher.php` lines 41-55: `127.0.0.0/8`, `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `169.254.0.0/16`, `100.64.0.0/10`, `0.0.0.0/8` for v4; `::1`, `fe80::/10`, `fc00::/7` for v6), plus `IsMulticast()`/`IsUnspecified()`, applied to `addr.Unmap()` so a v4-mapped-v6 literal is classified as its v4 form first. This list is explicitly a **contract-parity target**, not "whatever `net/netip` happens to flag" — Go may be *stricter* than PHP (CONTEXT: "Go may be safer than PHP... never looser") but must not be laxer by missing one of these ranges.
+**Phase to address:** This phase (D-12).
+
+### Pitfall 7: DNS-rebinding gap — PHP resolves once, fetches once; Go's dial-time check must be the actual fix, not a copy of PHP's gap
+**What goes wrong:** `ManualCoverUrlFetcher.php`'s own doc comment admits: "Known limitation (DNS rebinding TOCTOU)... the IP check pre-resolves the host, but the HTTP client resolves again before fetching." A literal Go port of this pattern (resolve-then-check-then-separately-fetch) reproduces the same TOCTOU window. CONTEXT D-12 explicitly requires closing this gap in Go via `net.Dialer.Control`, which fires with the exact IP about to be connected, after Go's own internal DNS resolution — this is strictly a Go-side improvement, not parity with the PHP behavior, and should be called out as such rather than treated as "porting" the vulnerable pattern.
+**How to avoid:** Use `http.Transport{DialContext: (&net.Dialer{Control: checkIP}).DialContext}` (or `net.Dialer.ControlContext`) so the IP classification runs on the address actually being dialed, on every connection attempt (including any the transport makes internally for keep-alive/retry). `http.Client{CheckRedirect: func(...) error { return http.ErrUseLastResponse }}` (or return a sentinel error) to hard-disable redirect following, matching PHP's `allow_redirects: false`.
+**Phase to address:** This phase (D-12).
+
+### Pitfall 8: `Content-Length` is not a byte cap — streaming enforcement is required
+**What goes wrong:** PHP's `ManualCoverUrlFetcher` comment explains exactly why a post-hoc `strlen($body) > $maxBytes` check is insufficient on its own: "a malicious/slow-drip origin that passes the SSRF gate could otherwise stream an unbounded body within the timeout, fully buffered by the HTTP client before the post-hoc `strlen()` check ever ran" — hence Guzzle's `progress` callback aborting mid-download. The Go equivalent must cap bytes **while reading**, not after buffering the full response.
+**How to avoid:** Wrap the response body in `io.LimitReader(resp.Body, maxBytes+1)` and treat reading exactly `maxBytes+1` bytes (i.e., the limit was hit) as the `too_large` failure — do not `io.ReadAll` an unbounded body first. Combine with a context deadline / `http.Client.Timeout` for the overall-timeout requirement (D-14).
+**Phase to address:** This phase.
+
+### Pitfall 9: The router is GET-only today — this phase must grow it, and that growth must not silently make `surf` app-aware
+**What goes wrong:** `surf/router.go` and `pact.Router` currently expose only `Get(path, handler, middleware...)` — no `Post`/`Put`/`Patch`/`Delete`, no generic `Handle(method, ...)`. Every PHP route group this phase must structurally declare (D-15's "all seven PHP route groups exist... as group builders") includes non-GET verbs (`Route::post`, `Route::put`, `Route::delete`, `Route::patch` throughout `routes.php`). Extending `pact.Router` is a **required framework-repo change**, not an app-repo-only task, and both `surf/router.go`'s `add()`/`wrap()`/`compile()` machinery (currently hardcoded `"GET "`) and the interface in `pact/capabilities.go` need it.
+**How to avoid:** Add `Post`, `Put`, `Patch`, `Delete` (or a single variadic `Handle(method string, ...)`) to both `pact.Router` and `surf.Router`/`Group`, generalize `add()`'s `key := "GET " + full"` to use the actual method, and generalize `compile()`'s `mux.Handle("GET "+rt.path, h)` the same way. Do this once, early in the phase's plan sequence, since every other Wave depends on it.
+**Warning signs:** A plan that tries to mount POST/PUT/DELETE routes on the existing `Get`-only router without first extending it.
+**Phase to address:** This phase, Wave 1 (foundational — blocks route-group work).
+
+### Pitfall 10: `/_fonoteka/api/*` deliberately gets zero CORS headers; `/api/v1/fonoteka/*` gets wildcard CORS
+**What goes wrong:** `config/cors.php`'s `paths` list is `['api/*', '_user/api/*', '_journal/api/*', '_feedback/api/*', 'oauth/mcp/*']` — note `_fonoteka/api/*` (leading underscore) is **absent**, so the JWT group gets no `Access-Control-Allow-*` headers at all (same-origin only, per the production runbook's "SPA and API are same-origin; CORS is a fallback, not the model"). The personal-token group `/api/v1/fonoteka/*` **does** match the `api/*` glob and gets `allowed_origins: ['*']`, confirmed directly in the recorded parity fixture (`GET__api_v1_fonoteka_genres_personal_token.yaml`'s response headers include `"Access-Control-Allow-Origin": "*"`). A Go port applying one blanket CORS policy across all groups gets this backwards in one direction or the other.
+**How to avoid:** CORS must be path-scoped exactly like Laravel's config (`paths` glob list, not a single global policy) — D-18's requirement. Test both groups explicitly: JWT group has no CORS headers, personal-token group has wildcard CORS headers, matching the recorded fixtures.
+**Phase to address:** This phase.
+
+## Code Examples
+
+### Fixed-window limiter Store, mirroring `Illuminate\Cache\RateLimiter`
+```go
+// Source: ported control flow from
+// /media/nvme/dev/golem15/fonoteka/vendor/laravel/framework/src/Illuminate/Cache/RateLimiter.php
+// (hit/tooManyAttempts/availableIn), not a generic rate-limiter design.
+type Store interface {
+ // Hit increments key's counter, opening a decay-second window on first hit
+ // (first hit wins — an existing unexpired window is never extended).
+ Hit(key string, decay time.Duration) (attempts int)
+ // TooManyAttempts mirrors RateLimiter::tooManyAttempts: true only while both
+ // the counter is >= max AND the window has not expired; an expired window
+ // resets the counter as a side effect, matching PHP's resetAttempts() call.
+ TooManyAttempts(key string, max int) bool
+ AvailableIn(key string) time.Duration
+}
+```
+
+### Dial-time SSRF guard shape
+```go
+// Source: pattern for net.Dialer.Control per CONTEXT D-12; IP table ported from
+// /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/ManualCoverUrlFetcher.php
+// lines 41-55 (PRIVATE_V4_CIDRS / PRIVATE_V6_PREFIXES).
+func dialControl(policy Policy) func(network, address string, c syscall.RawConn) error {
+ return func(network, address string, c syscall.RawConn) error {
+ host, _, err := net.SplitHostPort(address)
+ if err != nil {
+ return err
+ }
+ addr, err := netip.ParseAddr(host)
+ if err != nil {
+ return fmt.Errorf("fetchguard: unparseable dial address %q", host)
+ }
+ addr = addr.Unmap() // normalize ::ffff:a.b.c.d to a.b.c.d before classifying
+ if isReservedOrPrivate(addr) {
+ return fmt.Errorf("fetchguard: private_ip")
+ }
+ if policy.Mode == AllowHostsMode && !policy.hostAllowed(/* the original hostname, not addr */) {
+ return fmt.Errorf("fetchguard: host not allow-listed")
+ }
+ return nil
+ }
+}
+```
+Note: the allow-list check (dotted-suffix match, e.g. `evil-discogs.com` must never match `.discogs.com`) must be done against the **original hostname**, not the resolved IP — `Dialer.Control` receives the already-resolved `address`, so the hostname allow-list check happens earlier (before `DialContext` is even invoked, from the `http.Client`'s own request), while the *IP* private-range check happens inside `Control` at actual-connect time. Both checks are needed; they run at different points in the request lifecycle.
+
+## State of the Art
+
+| Old Approach | Current Approach | When Changed | Impact |
+|--------------|------------------|---------------|--------|
+| `net.IP` predicate methods (`IsPrivate`, `IsLoopback`, etc.) | `net/netip.Addr` (value type, `Is4In6`/`Unmap` for IPv4-mapped-IPv6 normalization) | `net/netip` added Go 1.18 (2022); this project already targets Go 1.27 | Prefer `netip.Addr` for the SSRF guard's IP classification — it has the explicit `Unmap()`/`Is4In6()` handling `net.IP` lacks, closing a real bypass vector (an attacker supplying `::ffff:169.254.169.254`) |
+| Token-bucket rate limiting as the default mental model | Laravel's actual fixed-window implementation (verified from vendor source this session) | N/A — this was never true of Laravel; it's a common *misconception* about what "the rate limiter" does, not a library upgrade | Do not assume any "modern" rate-limiter pattern applies; match the specific fixed-window semantics read from source |
+
+**Deprecated/outdated:** None identified specific to this phase's libraries — swaggo/swag v1 is current stable (v2 is RC, not recommended per STACK.md), openapi-typescript 7.x is current.
+
+## Assumptions Log
+
+| # | Claim | Section | Risk if Wrong |
+|---|-------|---------|---------------|
+| A1 | Production `APP_DEBUG=false`, so the 429/error JSON body shape is the non-debug `{"message":"Too Many Attempts."}` form (not the debug form with `trace`) | Common Pitfalls #2 | If the parity fixtures were actually recorded with `APP_DEBUG=true` (dev `.env` default), the Go port's 429 body would need the debug shape instead, or the fixtures themselves need re-recording against a debug-false PHP instance before Go is diffed against them. `docs/deploy/plytarium.com.md` states production must be `APP_DEBUG=false`, but this research did not directly inspect the PHP instance the Phase 2 parity harness recorded fixtures from. **Recommend a `checkpoint:human-verify` or a direct `.env`/harness-config check at plan time before finalizing the 429 body shape.** |
+| A2 | Production nginx `client_max_body_size`/PHP `post_max_size`/`upload_max_filesize` values are NOT found anywhere in the `fonoteka` repo (checked `docs/deploy/`, searched the whole `golem15` tree for `.conf`/`.ini` files referencing these directives — only the unrelated `fonoteka-mcp` subdomain nginx conf exists) | D-18 / HTTP-09 | The Go framework's `http.MaxBytesReader` ceilings cannot be set to "match the PHP deployment" without knowing those numbers; they appear to live only on the production host, outside version control (consistent with `docs/deploy/plytarium.com.md`'s "Do not overwrite an operator-owned nginx vhost in the dark" framing — the vhost itself is genuinely not in this repo). **This must go to the user or the production host as a `checkpoint:human-verify`, not be guessed from PHP/nginx defaults.** A defensible interim default: PHP's own out-of-the-box `post_max_size=8M`/`upload_max_filesize=2M`, but album/collection photo uploads plus the 10 MiB cover-fetch cap (D-14, which *is* documented) suggest the real host value is higher than PHP's stock default — do not ship a guessed number as if it were verified. |
+| A3 | The inline `throttle:N,M` key for an authenticated request resolves to `sha1($user->getAuthIdentifier())`, i.e., keyed by user id, not IP, on the JWT group's fixed-throttle routes (`switch`, `collection/share/regenerate`, `wishlist/share/regenerate`, `household/invitations`, `household/invitations/{id}/resend`, `oauth-identities/{provider}` DELETE, `import/csv`) | Common Pitfalls #4 | Verified directly from `ThrottleRequests::resolveRequestSignature()` vendor source — HIGH confidence, not really an assumption, but flagged because the Go port's exact key-string format (raw user id vs. a Go-side hash) is a discretion item the planner must still pin down explicitly |
+| A4 | `swag --version` printing `v1.16.4` after a `go install ...@v1.16.6` is a benign embedded-version-string lag, not evidence the wrong module was installed | Standard Stack / Version verification | If wrong, the CI/build-time `swag` binary could be running older-than-expected annotation-parsing logic; low risk since `go list -m` confirmed the module resolves to v1.16.6, but the discrepancy itself was not root-caused (e.g., by inspecting swag's own release-tagging history) |
+
+## Open Questions
+
+1. **Was the Phase 2 parity harness's recorded PHP fixtures captured with `APP_DEBUG=true` or `false`?**
+ - What we know: dev `.env` defaults to `true`; production runbook mandates `false`; the parity harness (Phase 2) runs against an "Isolated PHP" instance on `127.0.0.1:8423` per STATE.md, whose own `APP_DEBUG` setting was not inspected this session.
+ - What's unclear: whether the 429/error JSON bodies in already-recorded fixtures (and any new ones the phase 6 planner records) reflect the debug or non-debug shape.
+ - Recommendation: before finalizing the error-body shape in a plan, grep the actual parity harness's PHP boot config (`check-phase2.sh --fresh-php` env, referenced in STATE.md) for `APP_DEBUG`, or record one live 429 fixture and inspect its body directly.
+
+2. **What are the real production `client_max_body_size` / `post_max_size` / `upload_max_filesize` values?**
+ - What we know: not present anywhere in the `fonoteka` repo; the production nginx vhost is explicitly operator-managed and off-repo per `docs/deploy/plytarium.com.md`.
+ - What's unclear: the actual numbers.
+ - Recommendation: `checkpoint:human-verify` — ask the user/operator directly, or SSH-inspect the host per the (out-of-scope-for-this-agent) runbook. Do not ship a guessed default silently as if verified.
+
+3. **Does the limiter's `Store` sweep interval need to be configurable per-environment (e.g., faster sweep in tests)?**
+ - What we know: CONTEXT marks this "Claude's Discretion."
+ - What's unclear: whether the plan needs a config key (`http.ratelimit.sweep_interval`) or a hardcoded reasonable default (e.g., 1 minute) is sufficient for v1.
+ - Recommendation: default to a hardcoded interval (e.g., matching the longest bucket's decay, 2 minutes) unless a test needs to control it directly via a constructor parameter — avoid adding a new config surface for something with no current multi-environment need.
+
+## Environment Availability
+
+| Dependency | Required By | Available | Version | Fallback |
+|------------|------------|-----------|---------|----------|
+| Go toolchain | All framework/app code this phase | ✓ | go1.27.0-X:nodwarf5 linux/amd64 | — |
+| `github.com/swaggo/swag` module | OpenAPI generation | ✓ (resolves via `go list -m`) | v1.16.6 | — |
+| `swag` CLI binary | Manual/local doc generation, `summer openapi:generate` implementation | ✓ (installable via `go install github.com/swaggo/swag/cmd/swag@v1.16.6`) | reports `v1.16.4` (see Assumption A4) | — |
+| `openapi-typescript` (npm) | TS type generation for the admin SPA (consumed fully in Phase 10, but the pipeline should exist by end of Phase 6 per D-08/HTTP-08) | ✓ (npm registry reachable, package resolves) | 7.13.0 | — |
+| Node/npm toolchain (for `openapi-typescript`) | Same | not directly probed this session — `npm view` succeeded from this shell, implying `npm` is on PATH | — | If absent in the actual execution environment, `openapi-typescript` generation becomes a manual/CI-only step; flag if `npm --version` fails at plan/execute time |
+
+**Missing dependencies with no fallback:** none identified.
+**Missing dependencies with fallback:** none identified — `npm`/Node availability should be reconfirmed at execute time since this research session only inferred it from a successful `npm view` call, not an explicit `npm --version` probe.
+
+## Validation Architecture
+
+### Test Framework
+| Property | Value |
+|----------|-------|
+| Framework | Go stdlib `testing` + `stretchr/testify` (assert/require only, per STACK.md) |
+| Config file | none — plain `go test ./...`; testcontainers-gated integration tests use `-short` skip per existing project convention (STACK.md "Dev loop and linting") |
+| Quick run command | `go test ./bouncer/... ./surf/... ./...fetchguard.../ -run . -short` (adjust package paths once new packages exist) |
+| Full suite command | `go test ./... -race` (project's established pattern from Phase 1-5 plans; testcontainers Postgres tests included) |
+
+### Phase Requirements → Test Map
+| Req ID | Behavior | Test Type | Automated Command | File Exists? |
+|--------|----------|-----------|-------------------|-------------|
+| HTTP-03 | Same handler serves JWT `/_fonoteka/api/v1/genres` and personal-token `/api/v1/fonoteka/genres`; unknown/malformed ids on ownership-scoped resources both 404 | integration (httptest against assembled router) | `go test ./fonoteka.go/... -run TestGenresSharedHandler` | ❌ Wave 0 |
+| HTTP-04 | Five named buckets + inline throttles + stacking enforced with documented keys/limits | unit (Store) + integration (full pipeline) | `go test ./surf/... -run TestLimiter` | ❌ Wave 0 |
+| HTTP-05 | Guard registry: two real guards resolve to the same `bouncer.User(ctx)` accessor; duplicate/unknown guard name fails boot | unit | `go test ./bouncer/... -run TestGuardRegistry` | ❌ Wave 0 |
+| HTTP-06 | Response conventions (`[]`, `+00:00`, tri-state bool, omitted keys); OAuth route carries no house envelope/error middleware | unit (types) + route-table inspection test | `go test ./surf/... -run TestResponseTypes|TestRawGroupExemption` | ❌ Wave 0 |
+| HTTP-07 | Fetch helper rejects non-allow-listed host, enforces byte cap + timeout | unit (httptest servers, per D-13) | `go test ./.../fetchguard/... -run TestFetch` | ❌ Wave 0 |
+| HTTP-08 | swag-generated OpenAPI + openapi-typescript produces valid TS types | build-time / smoke (not a `go test`) | `swag init ... && npx openapi-typescript ... --check` (exact commands TBD at plan time) | ❌ Wave 0 |
+| HTTP-09 | CORS path-scoped; JSON body-size limits enforced | integration (httptest, both groups) | `go test ./surf/... -run TestCORS|TestBodyLimit` | ❌ Wave 0 |
+
+### Sampling Rate
+- **Per task commit:** targeted package `go test .//... -short`
+- **Per wave merge:** `go test ./... -short`
+- **Phase gate:** `go test ./... -race` green, plus the parity harness re-run on the genres routes (both auth groups) before `/gsd:verify-work`
+
+### Wave 0 Gaps
+- [ ] `surf/limiter_test.go` — fixed-window Store semantics (hit/tooManyAttempts/availableIn), named-bucket resolution, stacking
+- [ ] `bouncer/registry_test.go` — guard register/duplicate-fail/resolve-by-name
+- [ ] A new SSRF-guard package's `fetch_test.go` — httptest servers for each documented failure reason (`invalid_url`, `scheme`, `unresolvable`, `private_ip`, `network_error`, `http_status`, `content_type`, `too_large`)
+- [ ] `surf/routetable_test.go` — raw-group middleware-refusal-at-registration test
+- [ ] Framework install: none — `testing` + `testify` already present; no new framework needed
+
+## Security Domain
+
+### Applicable ASVS Categories
+
+| ASVS Category | Applies | Standard Control |
+|---------------|---------|-------------------|
+| V2 Authentication | yes | Two guards (`jwt` — existing HS256 verify with required `exp`/`sub`; `inv_token` — SHA-256 hash lookup against `golem15_fonoteka_api_tokens.token_hash`, expiry/revocation check via `ApiToken.IsUsable()`-equivalent) |
+| V3 Session Management | no (this phase) | Stateless bearer tokens only; no server-side session store touched here |
+| V4 Access Control | yes | `inv.scope:` deny-by-default gate; route-group mutual exclusion (auth-isolation invariant: zero `tokens`/`oauth` routes on the personal-token group, zero `inv.scope` on the JWT group) enforced structurally via the route table, not by convention |
+| V5 Input Validation | yes | `Where`/`WhereIn` path-param constraints (existing `surf/params.go`); SSRF fetch helper's URL/scheme/host/IP validation chain |
+| V6 Cryptography | yes | Token hash lookup (SHA-256, matches PHP's `hash('sha256', ...)` — not a password hash, a lookup-key hash, so this is *not* a bcrypt/argon2 case); `crypto/subtle.ConstantTimeCompare` required anywhere a raw secret is compared byte-for-byte (not the indexed-hash-lookup path itself, which is safe by construction, but any future direct compare) |
+
+### Known Threat Patterns for this stack
+
+| Pattern | STRIDE | Standard Mitigation |
+|---------|--------|----------------------|
+| SSRF via user-supplied `cover_url` (manual upload) reaching internal services / cloud metadata endpoint | Elevation of Privilege / Information Disclosure | Dial-time IP re-check (`net.Dialer.Control`) after https-only + host validation, redirects disabled, byte-capped streaming read, request timeout (D-11–D-14) |
+| DNS rebinding TOCTOU (resolve-time check passes, connect-time IP differs) | Tampering | Dial-time check closes this — PHP's own code admits it does *not* close it (Pitfall 7) |
+| Token/scope confusion — a personal token reaching a JWT-only route, or vice versa | Elevation of Privilege | Route-table test asserting group mutual exclusivity (D-15/D-16); `inv.scope` denies by default |
+| Rate-limit bypass via spoofed `X-Forwarded-For` | Denial of Service | Trusted-proxy CIDR gate (D-04) — `X-Forwarded-For` honored only when `RemoteAddr` is inside the configured trusted range |
+| Stack-trace/PII leak on a public, unauthenticated 429 (the exact documented reason `PublicShareHeaders.php` exists) | Information Disclosure | JSON-only 429 rewrite on the public-share group; verify `APP_DEBUG`-equivalent (Go has no such global — ensure the Go error path never includes Go stack traces in a JSON body, structurally, not via a debug flag) |
+| Guard registry duplicate/unknown-name misconfiguration silently no-op'ing auth | Spoofing | Fail boot loudly on duplicate/unknown guard names (mirrors existing `RegisterMiddleware` pattern) |
+| Timing side-channel on secret comparison (name-checked in PITFALLS.md, relevant to future OAuth client-secret work seeded this phase via the guard registry pattern) | Information Disclosure | `crypto/subtle.ConstantTimeCompare`; not directly exercised by the SHA-256-indexed-lookup token path this phase, but the *pattern* must be established now since Phase 8's OAuth guard reuses this registry |
+
+## Sources
+
+### Primary (HIGH confidence — read directly this session)
+- `/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/routes.php` (full file, 568 lines)
+- `/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/middleware/TokenScope.php`
+- `/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/middleware/PublicShareHeaders.php`
+- `/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/auth/ApiTokenGuard.php`, `ApiTokenManager.php`
+- `/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/ManualCoverUrlFetcher.php`
+- `/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/discogs/CoverImporter.php`
+- `/media/nvme/dev/golem15/fonoteka/config/cors.php`, `config/cache.php`
+- `/media/nvme/dev/golem15/fonoteka/plugins/golem15/user/middleware/JwtAuthenticate.php`
+- `/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/traits/SerializesFonoteka.php`
+- `/media/nvme/dev/golem15/fonoteka/vendor/laravel/framework/src/Illuminate/Routing/Middleware/ThrottleRequests.php`
+- `/media/nvme/dev/golem15/fonoteka/vendor/laravel/framework/src/Illuminate/Cache/RateLimiter.php`
+- `/media/nvme/dev/golem15/fonoteka/vendor/laravel/framework/src/Illuminate/Http/Exceptions/ThrottleRequestsException.php`
+- `/media/nvme/dev/golem15/fonoteka/vendor/laravel/framework/src/Illuminate/Foundation/Exceptions/Handler.php`
+- `/media/nvme/dev/golem15/fonoteka/vendor/winter/storm/src/Foundation/Exception/Handler.php`
+- `/media/nvme/dev/golem15/fonoteka/composer.lock` (laravel/framework version pin: `9.x-dev`, ref `feb47bae900315eb9b030de98626b384cbbb86ac`, 2026-08-07)
+- `/media/nvme/dev/golem15/fonoteka/docs/deploy/plytarium.com.md` (production `APP_DEBUG=false`, same-origin/CORS-is-fallback model)
+- `../fonoteka.go/parity/manifest.yaml` (154 routes, `auth_group` distribution: jwt 105, personal_token 34, oauth 4, onboarding 2, public_share 6, public_invitation 1, jwt_locale 2)
+- `../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}_albums_public_share.yaml`, `GET___fonoteka_api_v1_invitations_{token}_public_invitation.yaml`, `GET__.well-known_oauth-authorization-server_oauth.yaml`
+- `surf/router.go`, `surf/params.go`, `surf/serve.go`, `bouncer/jwt.go`, `bouncer/context.go`, `pact/capabilities.go`, `backpack/app.go`, `compass/config.go`, `towel/*.go` (all read directly, this repo)
+- `../fonoteka.go/plugins/golem15/fonoteka/routes.go`, `plugin.go`, `models/api_token.go` (Go seams, read directly)
+- Local Go 1.27 toolchain `go doc` output for `net.Dialer`, `net.IP`, `net/netip.Addr` (this session)
+- `npm view openapi-typescript version|time.created|repository.url` (this session)
+- `go list -m github.com/swaggo/swag@v1.16.6` (this session)
+- slopcheck tool run against `github.com/swaggo/swag` (this session)
+
+### Secondary (MEDIUM confidence)
+- `.planning/research/PITFALLS.md` §Pitfall 4, 6, 7, 8, 9, §Rate-limit bucket parity, §Security Mistakes (this project's own prior research pass, cross-checked against the PHP source this session and found accurate)
+- `.planning/research/ARCHITECTURE.md` §Request Flow (matches the code read this session; the "cooler-backed limiter" phrase is explicitly superseded by CONTEXT D-03 for this phase)
+- `.planning/research/STACK.md` §OpenAPI generation, §What NOT to Use (swag/openapi-typescript versions re-verified this session, found current)
+
+### Tertiary (LOW confidence)
+- None used as the basis for a stated fact in this document — where information was unavailable (production body-size limits), it is recorded as an open question/assumption, not asserted.
+
+## Metadata
+
+**Confidence breakdown:**
+- Standard stack: HIGH — every version re-verified via registry/module-proxy tools this session, not carried over from training data
+- Architecture (auth groups, raw-group exemption, response conventions): HIGH — every referenced PHP behavior was read from source, not recalled
+- Rate limiting (D-01/D-02): HIGH on PHP semantics (read from installed vendor code, not framework docs), MEDIUM on the exact Go `Store` API shape (a design choice, correctly left as discretion)
+- SSRF fetch helper: HIGH on the PHP contract (both classes read in full) and on Go stdlib capabilities (`go doc` verified this session); MEDIUM on "the" correct Go idiom since this is a well-known pattern but not benchmarked against a CVE database this session
+- Body-size limits (D-18 half): LOW — genuinely not found in the repo; flagged as Assumption A2 / Open Question 2, not guessed
+
+**Research date:** 2026-09-19
+**Valid until:** ~30 days for the Go/library facts (stable ecosystem); the two flagged Open Questions (APP_DEBUG at fixture-recording time, production body-size limits) should be resolved before or during planning, not left until execution — they affect concrete config defaults the plan will write.