Files
summercms/.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-RESEARCH.md
2026-09-19 23:13:24 +02:00

590 lines
74 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 - 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>
## 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:<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.
### 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).
</user_constraints>
<phase_requirements>
## 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` |
</phase_requirements>
## 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:<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:<id>` 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:<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 (RESOLVED)
1. **`APP_DEBUG` contract — RESOLVED: `false`.**
- The Phase 6 implementation pins the isolated PHP parity recorder to `APP_DEBUG=false`, matching the production runbook and establishing the non-debug 429 body `{"message":"Too Many Attempts."}` as the contract. Three older HTML exception fixtures recorded under debug remain explicitly flagged for re-recording when their routes are ported; they are not 429 contract fixtures.
2. **Production body limits — RESOLVED: operator-confirmed `128M` for all three limits.**
- On 2026-09-19 the operator confirmed nginx `client_max_body_size=128M`, PHP `post_max_size=128M`, and PHP `upload_max_filesize=128M`. Using binary megabytes, both Go config keys are therefore `134217728` bytes (`http.body_limits.default_bytes` and `http.body_limits.upload_bytes`), as recorded and verified by Plan 06-03.
3. **Limiter `Store` sweep interval — RESOLVED: fixed two-minute interval.**
- No environment config key was added. `surf.BuildRouter` constructs `NewMemoryStore(2*time.Minute)`, twice the longest one-minute bucket decay; tests may still pass a shorter constructor interval to exercise cleanup without expanding production configuration surface.
## 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 ./<changed-package>/... -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:<read|write|ai>` 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.