74 KiB
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, keytok:<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 inlinethrottle:N,Mmechanism (PHP uses10,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 ofgolem15.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
ThrottleRequestswire parity: fixed window per key (first hit opens the window),X-RateLimit-LimitandX-RateLimit-Remainingon limited responses,Retry-AfterandX-RateLimit-Reseton 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. Nootter, nocoolerpackage this phase. - D-04: Client IP comes from a trusted-proxy rule: a
http.trusted_proxiesCIDR list;X-Forwarded-Foris honored only whenRemoteAddris inside that list, taking the rightmost untrusted hop; empty list meansRemoteAddronly. One framework function is the single source of client IP for limiter keys (and later logging). - D-05: Parameterized middleware names are a
surffeature:throttle:10,1,throttle:fonoteka-public-ipandinv.scope:writeare 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:
bouncergets a named guard registry. Plugins register at Register time under a name (jwt,inv_token;oauthlater) a Guard with an authenticate-request → principal shape. Auth middleware is derived from a guard by name. Every guard writes the samebouncer.User(ctx)accessor, plus an optional credential accessor (the resolved token) thatinv.scopeand thetok:<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
ApiTokenmodel 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 thegolem15.fonotekaplugin, 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>"}. Theinv_tokenguard is registered bygolem15.fonotekatoo, mirroring PHP ownership. - D-09: The
oauthguard 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 registersoauth. - D-10: The Phase 3 JWT verifier, its 401 bodies and the 423 gate are kept unchanged;
jwt.authis re-expressed as thejwtguard 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, soevil-discogs.comnever passes) andPublicOnly(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.DialerControl 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
httptestservers), with typed failure reasons that map onto PHP'sinvalid_url/unresolvable/private_ip/network_error/too_largefamily.ManualCoverUrlFetcherandCoverImporterports 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.goas group builders with their exact middleware stacks, but only routes with real handlers are mounted. The shared-handler proof isGET genresunder both/_fonoteka/api/v1(JWT) and/api/v1/fonoteka(inv.scope:read), which flipsGET__api_v1_fonoteka_genres_personal_tokento 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 staypendinguntil 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:listcommand; success criterion 3's "verified by route-registration inspection" is a test over that table. The/.well-known/oauth-authorization-serverand/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:00time, 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 reproducesconfig/cors.phpexactly — including that_fonoteka/api/*is not listed, so the JWT group gets no CORS headers, as today. JSON body limits usehttp.MaxBytesReaderper 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,Mkey composition, as long as it matches Laravel's (D-02 research). - swag workflow: which repo holds annotations and generated spec, the
summercommand or script that runs swag andopenapi-typescript, and a drift check. Default: annotations on the real fonoteka handlers, generated spec committed infonoteka.go, types validity checked in the phase gate script. - Locale stage depth beyond Phase 3's
Accept-Languageread, 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-verifybuckets — 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.
coolercache package on otter — when a phase actually needs an in-process cache.ManualCoverUrlFetcherandCoverImporterports 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/httpServeMux,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 vetandgo 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()-timeRegister()calls), matching the existingRegisterMiddleware/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
jwtguard'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 theinv_tokenguard,inv.scope, the five fonoteka buckets, and the group builders. Confirmed against the existing code split (bouncer/surfinsummercms.govs.plugins/golem15/fonotekainfonoteka.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)
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):
// 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.reservationonserializeAlbum) 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-handlerifthat a future PR can forget. - Token-bucket rate limiting (
x/time/rate) standing in for PHP's fixed-windowRateLimiter: different algorithm, will not reproduce the exactX-RateLimit-Remainingsequence PHP'sRateLimiter::hit()/tooManyAttempts()produces (see Common Pitfalls). - Resolving the client IP from
RemoteAddrunconditionally, or fromX-Forwarded-Forunconditionally: CONTEXT D-04 requires a trusted-proxy CIDR gate; unconditionally trustingX-Forwarded-Forlets any client spoof their rate-limit key and bypassfonoteka-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
-
Was the Phase 2 parity harness's recorded PHP fixtures captured with
APP_DEBUG=trueorfalse?- What we know: dev
.envdefaults totrue; production runbook mandatesfalse; the parity harness (Phase 2) runs against an "Isolated PHP" instance on127.0.0.1:8423per STATE.md, whose ownAPP_DEBUGsetting 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-phpenv, referenced in STATE.md) forAPP_DEBUG, or record one live 429 fixture and inspect its body directly.
- What we know: dev
-
What are the real production
client_max_body_size/post_max_size/upload_max_filesizevalues?- What we know: not present anywhere in the
fonotekarepo; the production nginx vhost is explicitly operator-managed and off-repo perdocs/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.
- What we know: not present anywhere in the
-
Does the limiter's
Storesweep 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 ./... -racegreen, 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, stackingbouncer/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+testifyalready 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, reffeb47bae900315eb9b030de98626b384cbbb86ac, 2026-08-07)/media/nvme/dev/golem15/fonoteka/docs/deploy/plytarium.com.md(productionAPP_DEBUG=false, same-origin/CORS-is-fallback model)../fonoteka.go/parity/manifest.yaml(154 routes,auth_groupdistribution: 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.yamlsurf/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 docoutput fornet.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
StoreAPI 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 docverified 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.