Files
summercms/.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-RESEARCH.md

74 KiB
Raw Blame History

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:

# 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)
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):

// 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:

// 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):

// 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

// 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

// 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`
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`

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
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.