# Phase 6: HTTP routing, auth groups and rate limiting - Pattern Map **Mapped:** 2026-09-19 **Files analyzed:** 14 (new/modified, across `summercms.go` and `fonoteka.go`) **Analogs found:** 14 / 14 (all have at least a role-match; several are direct extensions of an existing file) ## File Classification | New/Modified File | Repo | Role | Data Flow | Closest Analog | Match Quality | |---|---|---|---|---|---| | `surf/router.go` (extend: Post/Put/Patch/Delete, raw-group flag, route table) | summercms.go | router | request-response | `surf/router.go` (self, extend in place) | exact | | `surf/routetable.go` (new) | summercms.go | utility (introspection) | request-response | `pact/capabilities.go` (`Router` interface shape) + `surf/router.go` (`route` struct) | role-match | | `surf/limiter.go` (new: real Limiter) | summercms.go | middleware | event-driven (counter state) | `surf/router.go` `Limiter`/`noopLimiter` (lines 336-347) | exact (seam already defined) | | `surf/limiter_store.go` (new: Store interface + mutex map) | summercms.go | service (in-process store) | CRUD (counter hit/reset) | `compass/config.go` (`sync.RWMutex`-guarded struct with a `view()`/`rebuild()` pattern) | role-match | | `surf/clientip.go` (new: trusted-proxy client IP) | summercms.go | utility | transform | `surf/params.go` (`Constraint`/pure-function utility file, no state) | role-match | | `surf/cors.go` (extend existing `cors()` in router.go into path-scoped, config-driven) | summercms.go | middleware | request-response | `surf/router.go` `cors()` (lines 303-322) | exact | | `bouncer/registry.go` (new: named Guard registry) | summercms.go | service (registry) | CRUD (register/resolve) | `surf/router.go` `RegisterMiddleware` (lines 67-80) | exact (explicitly named as the pattern to mirror by RESEARCH.md) | | `bouncer/guard.go` (new: `Guard`/`CredentialGuard` interfaces, `jwt` guard adapter) | summercms.go | service (auth) | request-response | `bouncer/jwt.go` `Middleware()` (lines 30-64) | exact | | new fetch-guard package, e.g. `fetchguard/fetch.go` | summercms.go | service (outbound I/O) | streaming | none in-repo (new capability) — nearest shape is `surf/serve.go`'s `net`/`syscall` stdlib usage and `compass/env.go`'s pure-function validation style | no analog (see below) | | new response-convention package, e.g. `wire/response.go` | summercms.go | utility (wire types) | transform | `fonoteka.go` `controllers/genre_controller.go` `writeJSON`/`writeOpaque500` (lines 136-153) | role-match (promote existing per-controller helper to a framework package) | | `../fonoteka.go/plugins/golem15/fonoteka/classes/auth/token_guard.go` (new: `inv_token` Guard) | fonoteka.go | service (auth) | CRUD (lookup ApiToken) | `bouncer/jwt.go` `Middleware()`/`Verify()` (lines 30-84) + `fonoteka.go` `models/api_token.go` | exact (same guard shape, new credential model) | | `../fonoteka.go/plugins/golem15/fonoteka/classes/auth/token_scope.go` (new: `inv.scope:` middleware) | fonoteka.go | middleware | request-response | `fonoteka.go` `middleware/must_change_password.go` (whole file) | exact | | `../fonoteka.go/plugins/golem15/fonoteka/routes.go` (extend: all 7 group builders, buckets) | fonoteka.go | route | request-response | `../fonoteka.go/plugins/golem15/fonoteka/routes.go` (self, extend in place) + `surf/router_test.go` `routePlugin.Routes` (lines 25-34, group-nesting shape) | exact | | `../fonoteka.go/plugins/golem15/fonoteka/plugin.go` (extend: register `inv_token` guard + 5 buckets at Boot) | fonoteka.go | config/bootstrap | event-driven (boot-time registration) | `../fonoteka.go/plugins/golem15/fonoteka/plugin.go` (self, extend in place) | exact | ## Pattern Assignments ### `surf/router.go` — extend with verbs, raw-group flag, route table (router, request-response) **Analog:** self (`surf/router.go`, full file already read) **Imports pattern** (lines 1-12): ```go package surf import ( "fmt" "net/http" "strings" "git.golem15.com/golem15/summercms/backpack" "git.golem15.com/golem15/summercms/pact" "git.golem15.com/golem15/summercms/party" "git.golem15.com/golem15/summercms/towel" ) ``` **Verb registration pattern to replicate for Post/Put/Patch/Delete** (lines 103-108, 124-129, 187-204): ```go func (r *Router) Get(path string, handler http.HandlerFunc, middleware ...string) { if r == nil { return } r.add(r.pluginID, r.prefix, r.middleware, path, handler, middleware) } // Group mirrors this with the same nil-guard shape (lines 124-129). func (r *Router) add(pluginID, prefix string, groupMW []string, path string, handler http.HandlerFunc, extra []string) { full := joinPath(prefix, path) key := "GET " + full // <-- generalize to use the real method for Post/Put/Patch/Delete if prev, ok := r.seen[key]; ok { r.compileErr = fmt.Errorf("surf: duplicate route %s registered by %s and %s", key, prev, pluginID) return } r.seen[key] = pluginID mw := append([]string{}, groupMW...) mw = append(mw, extra...) r.routes = append(r.routes, route{ pluginID: pluginID, method: "GET", // <-- generalize path: full, handler: handler, middleware: mw, }) } ``` `route.method` already exists on the struct (line 26) but is hardcoded to `"GET"` at both call sites (`add`, `compile`'s `mux.Handle("GET "+rt.path, h)` at line 216) — Pitfall 9 in RESEARCH.md names this exact spot. Add a `method string` parameter threaded through `add`/`Get`/`Post`/etc., and change `compile()`'s `mux.Handle(rt.method+" "+rt.path, h)`. **Group-nesting pattern (for raw-group flag)** (lines 50-56, 89-101, 110-122): ```go type Group struct { router *Router pluginID string prefix string middleware []string // raw bool <-- add here, inherited like middleware is (line 97-99) } func (r *Router) Group(prefix string, middleware []string, fn func(pact.Router)) { if r == nil || fn == nil { return } g := &Group{ router: r, pluginID: r.pluginID, prefix: joinPath(r.prefix, prefix), middleware: append([]string{}, r.middleware...), } g.middleware = append(g.middleware, middleware...) fn(g) } ``` A `Raw`-flagged group needs a **new capability method** on `pact.Router` (e.g. `GroupRaw(prefix string, middleware []string, fn func(Router))`) since `pact.Router` is a narrow interface (see `pact/capabilities.go` below) — extend the interface, not just the `surf` struct, or app code in `fonoteka.go` cannot call it. **Compile / wrap pattern (where raw-group must skip envelope/recover)** (lines 206-263): ```go func (r *Router) compile() (http.Handler, error) { if r.compileErr != nil { return nil, r.compileErr } mux := http.NewServeMux() for _, rt := range r.routes { h, err := r.wrap(rt) if err != nil { return nil, err } mux.Handle("GET "+rt.path, h) // generalize method } return recoverJSON(cors(r.origins, mux)), nil // raw groups need their own bare-500 wrapping, not this outer one } func (r *Router) wrap(rt route) (http.Handler, error) { h := constrain(rt.handler, rt.constraints) h = noOpLimit(h) h = orgSlot(h) for i := len(rt.middleware) - 1; i >= 0; i-- { name := rt.middleware[i] named, ok := r.named[name] if !ok { return nil, fmt.Errorf("surf: plugin %q references unknown middleware %q", rt.pluginID, name) } h = named.fn(h) } h = locale(h) return h, nil } ``` D-16's "fail boot if a house-envelope/error middleware is named on a raw group" belongs in this loop over `rt.middleware` — check `rt.raw` and reject any middleware name tagged house-envelope/error before building `h`. **Error message convention** (repeated throughout): `fmt.Errorf("surf: %q ...: %w"/", got/registered by %s and %s", ...)` — always prefixed `"surf: "`, always names the plugin and the offending identifier. Reuse verbatim for every new failure path (duplicate guard name, unknown guard name, raw-group violation). --- ### `surf/routetable.go` (new) — exported route table for tests + `route:list` (utility, introspection) **Analog:** `pact/capabilities.go`'s `Router` interface (lines 40-45) for the shape contract, and `surf/router.go`'s `route` struct (lines 24-31) for the fields to expose. **Existing `route` struct to project into a public `RouteInfo`:** ```go type route struct { pluginID string method string path string handler http.Handler middleware []string constraints []Constraint } ``` RESEARCH.md's own suggested shape (Pattern 3, lines 311-320) is already aligned with this struct — add `Raw bool` to `route` and project a read-only `[]RouteInfo` snapshot via a new `(r *Router) Routes() []RouteInfo` method, mirroring the getter-returns-copy style already used by `party.snapshot()` (`party/registry.go`, copies a slice under a mutex before returning it — same defensive-copy idiom to use here, though `Router` itself has no concurrent-write path post-`Assemble`). --- ### `surf/limiter.go` + `surf/limiter_store.go` (new) — real Limiter behind the existing interface (service, event-driven counter) **Analog 1 — the seam to fill in** (`surf/router.go` lines 336-347): ```go // Limiter wraps handlers. Phase 6 replaces the no-op with named buckets. type Limiter interface { Wrap(http.Handler) http.Handler } type noopLimiter struct{} func (noopLimiter) Wrap(next http.Handler) http.Handler { return next } func noOpLimit(next http.Handler) http.Handler { return noopLimiter{}.Wrap(next) } ``` The real limiter is a second implementation of this same `Limiter` interface; `noOpLimit`'s call site in `wrap()` (line 223, `h = noOpLimit(h)`) is where a named-bucket-aware limiter must be threaded in instead — the bucket name(s) come from `rt.middleware` entries like `throttle:fonoteka-public-ip`, so the limiter needs access to the per-route middleware list, not just a blanket wrap. **Analog 2 — mutex-guarded struct with rebuild/view split** (`compass/config.go` lines 39-48, 166-180): ```go type Config struct { mu sync.RWMutex opts Options // ... k *koanf.Koanf runtime *koanf.Koanf } func (c *Config) view() *koanf.Koanf { if c == nil { return nil } c.mu.RLock() defer c.mu.RUnlock() if c.k == nil { return nil } out := c.k.Copy() if c.runtime != nil { _ = out.Merge(c.runtime) } return out } ``` Mirror this `mu sync.RWMutex` + accessor-under-lock shape for the in-process `Store` (D-03): a `mu sync.Mutex`-guarded `map[string]*counterEntry`, with `Hit`/`TooManyAttempts`/`AvailableIn` each taking the lock internally (not exposing the map). Use `RWMutex` only if reads (`TooManyAttempts`) genuinely outnumber writes (`Hit`) — plain `Mutex` is simpler and matches the CONTEXT D-03 "mutex-guarded map" wording exactly. **Error/message conventions:** none apply directly (this is stdlib counters, no wire errors) — but keep the `"surf: "`-prefixed error convention for any constructor-time misconfiguration (e.g., unknown bucket name at `Group()`/route-registration time), matching `RegisterMiddleware`'s `fmt.Errorf("surf: middleware %q already registered by %s", ...)`. --- ### `surf/clientip.go` (new) — trusted-proxy-aware client IP (utility, transform) **Analog:** `surf/params.go` (full file, pure functions operating on `*http.Request`, no state, package-level `Constraint`/`IntParam`/`Regex`/`Enum`). **Pattern to mirror** (lines 22-35 style — request-in, value-and-bool-out, no receiver state): ```go // IntParam returns a positive integer path value. Missing or malformed ids // are false so callers can 404 both unknown and non-integer values. func IntParam(r *http.Request, name string) (int64, bool) { if r == nil { return 0, false } raw := r.PathValue(name) if raw == "" { return 0, false } n, err := strconv.ParseInt(raw, 10, 64) if err != nil || n < 1 { return 0, false } return n, true } ``` `ClientIP(r *http.Request, trusted []netip.Prefix) string` should follow this exact shape: nil-request guard first, then a linear decision chain, no panics, a safe zero-value return on any ambiguity (falls back to `RemoteAddr`, never to an unvalidated header). D-04 requires this to be "one framework function" — keep it a single exported function in this file, not a method on a stateful type, matching `params.go`'s style. --- ### `surf/cors.go` — path-scoped, config-driven CORS (middleware, request-response) **Analog:** `surf/router.go`'s existing `cors()` (lines 303-322), which is the direct predecessor this task extends/replaces. ```go func cors(origins []string, next http.Handler) http.Handler { allowed := make(map[string]struct{}, len(origins)) for _, o := range origins { allowed[o] = struct{}{} } return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { origin := r.Header.Get("Origin") if _, ok := allowed[origin]; ok && origin != "" { w.Header().Set("Access-Control-Allow-Origin", origin) w.Header().Set("Vary", "Origin") w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, Accept") w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS") } if r.Method == http.MethodOptions { w.WriteHeader(http.StatusNoContent) return } next.ServeHTTP(w, r) }) } ``` Currently applied once, globally, in `compile()` (`cors(r.origins, mux)`, line 218) — D-18 requires this to become **path-scoped** (glob list like PHP's `paths: ['api/*', ...]`), so the wrapping must move from a single blanket call in `compile()` to a per-group or per-route decision, reading `compass` config via the existing `corsOrigins(app *backpack.App)` helper pattern (lines 265-288) which already reads `http.cors.allowed_origins` off `app.Config.Lookup(...)` — extend that helper to also read `http.cors.paths`, `.methods`, `.headers`, `.max_age`, `.credentials`, matching `compass.Config.Lookup`/`.String`/`.Bool` accessors shown in `compass/config.go` lines 112-155. **Test proof pattern** (`surf/router_test.go` lines 76-110, `TestCORSPreflightBypassesNamedAuth`) — reuse this exact shape (register a fake `jwt.auth` middleware, assert it did NOT run on an OPTIONS preflight, assert the `Access-Control-Allow-Origin` header) to prove path-scoping: run it once against a path in `http.cors.paths` (expect headers) and once against `/_fonoteka/api/*` (expect no headers), per Pitfall 10. --- ### `bouncer/registry.go` (new) — named Guard registry (service, CRUD register/resolve) **Analog:** `surf/router.go` `RegisterMiddleware` (lines 67-80) — RESEARCH.md's own Pattern 1 (lines 245-272) already names this as the pattern to mirror; confirmed by reading the source directly. ```go // RegisterMiddleware stores a named wrapper. Duplicate names fail. func (r *Router) RegisterMiddleware(pluginID, name string, fn pact.Middleware) error { if r == nil { return fmt.Errorf("surf: router is nil") } if name == "" || fn == nil { return fmt.Errorf("surf: plugin %q registered empty middleware", pluginID) } if existing, ok := r.named[name]; ok { return fmt.Errorf("surf: middleware %q already registered by %s", name, existing.pluginID) } r.named[name] = namedMiddleware{pluginID: pluginID, fn: fn} return nil } ``` Port 1:1 into `bouncer.Registry.Register(pluginID, name string, g Guard) error` with `"bouncer: "` prefix instead of `"surf: "` (matching this package's own existing error-message convention seen in `bouncer/jwt.go`, e.g. `"bouncer: jwt secret is empty"` at line 69). Container type mirrors `namedMiddleware{pluginID string; fn pact.Middleware}` (line 19-22 of `surf/router.go`) → `namedGuard{pluginID string; guard Guard}`. **Duplicate-registration test pattern to replicate:** `surf/router_test.go`'s `TestMissingMiddlewareNamesPluginAndName` (lines 36-47) asserts the error string contains both the plugin id and the middleware name — write the equivalent `TestDuplicateGuardNamesBothPlugins`/`TestUnknownGuardNameFailsBoot` in `bouncer/registry_test.go` with the same assertion shape (`strings.Contains(err.Error(), ...)`). --- ### `bouncer/guard.go` (new) — `Guard`/`CredentialGuard` interfaces, re-express `jwt` as a guard (service, request-response) **Analog:** `bouncer/jwt.go` `Middleware()` (lines 30-64) — this becomes the guard body wrapped by the registry; D-10 requires zero behavior change. ```go func Middleware(secret string, users UserProvider) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { raw, err := bearerToken(r) if err != nil { write401(w, err.Error()) return } sub, err := Verify(raw, secret) if err != nil { write401(w, err.Error()) return } id, err := strconv.ParseUint(sub, 10, 64) if err != nil || id == 0 { write401(w, msgUserNotFound) return } if users == nil { write401(w, msgUserNotFound) return } user, err := users.FindByID(r.Context(), uint(id)) if err != nil { write401(w, "Authentication error") return } if user == nil { write401(w, msgUserNotFound) return } next.ServeHTTP(w, r.WithContext(WithUser(r.Context(), user))) }) } } ``` Wrap this exact function body (unchanged) as the `Authenticate(r *http.Request) (*Principal, error)` implementation of a `jwtGuard` struct, so `bouncer.Middleware(secret, users)` keeps working unmodified (D-10: "kept unchanged... re-expressed... without changing behavior") while `Registry.Middleware("jwt")` derives the same `func(http.Handler) http.Handler` generically from `Guard.Authenticate` + a shared 401-writer. Reuse `write401` (line 135-139) verbatim as the shared unauthorized-response writer for any guard that wants the `{"error":true,"message":...}` shape (only `jwt` uses this shape — `inv_token` uses a different shape, see below). **Context accessor pattern to extend** (`bouncer/context.go`, full file): ```go type userKey struct{} func WithUser(ctx context.Context, user *Principal) context.Context { if ctx == nil { ctx = context.Background() } return context.WithValue(ctx, userKey{}, user) } func User(ctx context.Context) (*Principal, bool) { if ctx == nil { return nil, false } u, ok := ctx.Value(userKey{}).(*Principal) return u, ok && u != nil } ``` Add a second unexported key (`credentialKey{}`) with the identical `WithCredential`/`Credential` accessor pair for D-06's "optional credential accessor" — same nil-guard-first, `context.WithValue`, type-assert-with-ok-bool shape. Do not add fields to `Principal`; keep the credential as a separate `any` accessor so `bouncer` never imports `models.ApiToken` (two-repo boundary, CLAUDE.md "framework never imports the app"). **Context round-trip test pattern** (`bouncer/jwt_test.go` lines 222-231, `TestContextUserRoundTrip`) — replicate verbatim for `TestContextCredentialRoundTrip`. --- ### `fetchguard/fetch.go` (new package) — SSRF-guarded outbound fetch (service, streaming) **No close in-repo analog** — this is a new capability (no existing outbound-HTTP-client code in either repo). Nearest structural precedents: - `surf/serve.go` (full file) for stdlib `net`/`syscall`/`context`-heavy code style in this codebase — note its signal-aware shutdown pattern is not relevant, but its plain, no-third-party-dependency stdlib composition (`http.Server`, `net.Listener`, `context.WithTimeout`) is the house style to match: no wrapper abstractions, direct stdlib types. - `bouncer/jwt.go`'s error-mapping style (`mapJWTError`, lines 115-133) — a `switch`/`errors.Is` chain converting a library error into one of a small closed set of named sentinel-ish errors — mirror this shape for mapping dial/read failures into the closed set `invalid_url`/`unresolvable`/`private_ip`/`network_error`/`too_large` (D-13). Use RESEARCH.md's own Code Examples section verbatim as the implementation skeleton (already vetted against the PHP source and Go stdlib capabilities this session): ```go 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(/* original hostname */) { return fmt.Errorf("fetchguard: host not allow-listed") } return nil } } ``` Package-naming convention: lower-case, no underscore, matches existing package names (`bouncer`, `surf`, `pact`, `compass`, `bonfire`, `lagoon`, `towel`, `backpack`, `party`) — `fetchguard` fits; `wire`/`parchment` (for the response package) also fit this one-word convention. --- ### `wire/response.go` (new package) — response-convention JSON writer + types (utility, transform) **Analog:** `fonoteka.go` `controllers/genre_controller.go` `writeJSON`/`writeOpaque500` (lines 136-153) — this is the exact pattern to promote from a per-controller private helper into a shared framework package. ```go func writeJSON(w http.ResponseWriter, status int, v any) { var buf bytes.Buffer enc := json.NewEncoder(&buf) enc.SetEscapeHTML(false) if err := enc.Encode(v); err != nil { writeOpaque500(w) return } w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _, _ = w.Write(bytes.TrimSuffix(buf.Bytes(), []byte("\n"))) } func writeOpaque500(w http.ResponseWriter) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusInternalServerError) _, _ = w.Write([]byte(`{"error":true,"message":"Internal server error"}`)) } ``` Note the buffer-then-trim-trailing-newline trick (`bytes.TrimSuffix(buf.Bytes(), []byte("\n"))`) and `SetEscapeHTML(false)` — both are deliberate wire-fidelity choices already established in this codebase; carry them into the framework `wire.WriteJSON`. The never-nil-slice convention is already independently established in `classes/serialize.go` (`SerializeAlbum`, lines 15-22): ```go tracklist := a.Tracklist.Get() if tracklist == nil { tracklist = []models.TrackEntry{} } failures := a.CoverImportFailures.Get() if failures == nil { failures = []string{} } ``` This nil-to-empty-slice guard is the pattern D-17's "never-nil slice helper" should generalize (e.g. `wire.Slice[T](s []T) []T { if s == nil { return []T{} }; return s }`), reused at every call site currently doing this inline. --- ### `../fonoteka.go/plugins/golem15/fonoteka/classes/auth/token_guard.go` (new) — `inv_token` Guard (service, CRUD lookup) **Analog:** `bouncer/jwt.go` `Middleware()` + `Verify()` (lines 30-84) for the guard shape; `../fonoteka.go/plugins/golem15/fonoteka/models/api_token.go` (full file, already read) for the model this guard reads: ```go type ApiToken struct { ID uint `gorm:"column:id;primaryKey"` UserID uint `gorm:"column:user_id"` Name *string `gorm:"column:name"` TokenHash string `gorm:"column:token_hash" json:"-"` Scopes lagoon.Jsonable[[]string] `gorm:"column:scopes"` CollectionIDs lagoon.Jsonable[[]uint] `gorm:"column:collection_ids"` ExpiresAt *time.Time `gorm:"column:expires_at"` RevokedAt *time.Time `gorm:"column:revoked_at"` LastUsedAt *time.Time `gorm:"column:last_used_at"` LastUsedIP *string `gorm:"column:last_used_ip"` OAuthClientID *string `gorm:"column:oauth_client_id"` CreatedAt time.Time `gorm:"column:created_at"` UpdatedAt time.Time `gorm:"column:updated_at"` } func (ApiToken) TableName() string { return "golem15_fonoteka_api_tokens" } func (ApiToken) Hidden() []string { return []string{"token_hash"} } ``` Model already has `ExpiresAt`/`RevokedAt`/`LastUsedAt`/`LastUsedIP` fields ready for D-07's expiry/revocation/last-used rules — no model change needed this phase, only a guard that reads them. Guard's `Authenticate` should look up by SHA-256 hash of the presented token against `TokenHash` (an indexed equality lookup, not a timing-sensitive compare per RESEARCH.md's V6 Cryptography note — `crypto/subtle` is not needed on this specific lookup path), then apply the same nil-guard/error-mapping shape `bouncer/jwt.go`'s `Middleware` uses (`bearerToken` → verify → `FindByID`-equivalent chain). **Registration site pattern:** `plugin.go`'s `Middlewares()` (lines 53-57) shows the existing shape for registering a named capability with the framework at Boot; the guard registers the same way but via `bouncer.RegisterGuard`/`Registry.Register` instead of `surf.RegisterMiddleware` — see `plugin.go` pattern assignment below. --- ### `../fonoteka.go/plugins/golem15/fonoteka/classes/auth/token_scope.go` (new) — `inv.scope:` middleware (middleware, request-response) **Analog:** `middleware/must_change_password.go` (full file, already read) — same plugin, same directory family, nearly identical shape (auth-context read → conditional 4xx JSON write → `next.ServeHTTP`): ```go package middleware import ( "encoding/json" "net/http" "git.golem15.com/golem15/summercms/bouncer" ) // MustChangePassword rejects authenticated users who must rotate their password. func MustChangePassword(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { user, ok := bouncer.User(r.Context()) if ok && user.MustChangePassword { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusLocked) _ = json.NewEncoder(w).Encode(map[string]any{ "error": "Password change required", "must_change_password": true, }) return } next.ServeHTTP(w, r) }) } ``` `InvScope(scope string)` differs only in: (a) it's a parameterized-middleware factory (`func(scope string) pact.Middleware`, not a bare `pact.Middleware`) — matches `throttle:N,M`'s parameterized-name convention (D-05); (b) two failure branches (401 no-credential, 403 wrong-scope) instead of one; (c) body key is `error` as a **string** (`{"error":"Invalid token"}` / `{"error":"Missing required scope: "}`), NOT the `{"error":true,"message":...}` shape `write401`/`MustChangePassword` use — RESEARCH.md's Pattern 2 (lines 274-301) flags this divergence explicitly and its example is ready to use as-is: ```go 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()) if !ok || principal == nil { writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "Invalid token"}) return } tok, _ := bouncer.Credential(r.Context()) 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) }) } } ``` `writeJSON` here should be the same helper as `controllers/genre_controller.go`'s (or the promoted `wire.WriteJSON`, once that package exists) — do not hand-roll a third JSON writer in this file. --- ### `../fonoteka.go/plugins/golem15/fonoteka/routes.go` — extend to all 7 groups (route, request-response) **Analog:** self (full file already read, 14 lines) — the file to extend in place, plus `surf/router_test.go`'s `routePlugin.Routes` (lines 25-34) for the nested-`Group` call shape once non-GET verbs and raw groups exist: ```go func (p *Plugin) Routes(r pact.Router) error { r.Group("/_fonoteka/api/v1", surf.Use("jwt.auth", "inv.must-change-password"), func(g pact.Router) { g.Get("/genres", controllers.ListGenres(p.app)) }) return nil } ``` Extend with a second `r.Group("/api/v1/fonoteka", surf.Use("inv_token", "inv.scope:read"), func(g pact.Router) { g.Get("/genres", controllers.ListGenres(p.app)) })` — literally the same handler function value (`controllers.ListGenres(p.app)`), proving D-15's shared-handler requirement by construction, not by convention. Keep `surf.Use(...)` string-list calling convention exactly as-is for every group (`throttle:10,1`, `inv.scope:write`, etc., per D-05) — this is the file where `routes.php`'s line-by-line shape must be visually preserved, so prefer one `r.Group(...)` block per PHP route group in the same order as `routes.php`. --- ### `../fonoteka.go/plugins/golem15/fonoteka/plugin.go` — extend Boot to register guard + buckets (bootstrap, event-driven) **Analog:** self (full file already read, 61 lines) — extend `Boot` and `Middlewares()`: ```go func (p *Plugin) Boot(app *backpack.App) error { p.app = app classes.SetDebug(app.Config != nil && app.Config.Bool("app.debug")) if gdb, ok := app.Lookup[*gorm.DB](); ok { if err := classes.RegisterHooks(gdb); err != nil { return err } } return nil } func (p *Plugin) Middlewares() map[string]pact.Middleware { return map[string]pact.Middleware{ "inv.must-change-password": middleware.MustChangePassword, } } ``` Add guard registration (`bouncer.RegisterGuard(p.ID(), "inv_token", auth.NewTokenGuard(gdb))`, guarded by the same `app.Lookup[*gorm.DB]()` presence check already used for hooks) and bucket registration (5 named buckets via whatever `surf`-side bucket-registration function the limiter package exposes — likely a new `pact.HasBuckets`-style capability interface mirroring `pact.HasMiddleware`'s `Middlewares() map[string]pact.Middleware` shape) inside `Boot`, after the existing `gdb` lookup. Add `"inv.scope:read"`/`"inv.scope:write"`/`"inv.scope:ai"` is NOT a fixed-name middleware map entry — it's parameterized (D-05), so it needs the same kind of factory-registration `surf.Use("inv.scope:write")` string-parsing the router already does for `throttle:N,M`; confirm at plan time whether `Middlewares()` needs a parallel `MiddlewareFactories()` capability or whether `surf`'s existing named-middleware resolution is extended to call a factory when the name contains `:`. --- ## Shared Patterns ### Named-registration-fails-on-duplicate **Source:** `surf/router.go` `RegisterMiddleware` (lines 67-80); also `party/registry.go` `Register`/`Activate` (duplicate/topo-sort-fail-boot family, lines 15-60+). **Apply to:** `bouncer/registry.go` (guard registry), any new bucket-registration function in `surf`. ```go func (r *Router) RegisterMiddleware(pluginID, name string, fn pact.Middleware) error { if r == nil { return fmt.Errorf("surf: router is nil") } if name == "" || fn == nil { return fmt.Errorf("surf: plugin %q registered empty middleware", pluginID) } if existing, ok := r.named[name]; ok { return fmt.Errorf("surf: middleware %q already registered by %s", name, existing.pluginID) } r.named[name] = namedMiddleware{pluginID: pluginID, fn: fn} return nil } ``` ### Opaque JSON 500 / house error envelope **Source:** `surf/router.go` `recoverJSON` (lines 290-301); `fonoteka.go` `controllers/genre_controller.go` `writeOpaque500` (lines 149-153); `bouncer/jwt.go` `write401` (lines 135-139). **Apply to:** Every non-raw-group handler and middleware; explicitly NOT the raw OAuth group (D-16 — raw group gets a bare 500, no JSON body, no `Content-Type`). ```go w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusInternalServerError) _, _ = w.Write([]byte(`{"error":true,"message":"Internal server error"}`)) ``` ### Context accessor pair (unexported key type, WithX/X functions) **Source:** `bouncer/context.go` (full file). **Apply to:** The new `Credential(ctx)`/`WithCredential(ctx, v)` accessor pair (D-06); `towel.WithLocale`/`towel.WithOrganization` (referenced in `surf/router.go` lines 326, 332) are the same pattern already used twice in this codebase for request-scoped context state. ```go type userKey struct{} func WithUser(ctx context.Context, user *Principal) context.Context { if ctx == nil { ctx = context.Background() } return context.WithValue(ctx, userKey{}, user) } func User(ctx context.Context) (*Principal, bool) { if ctx == nil { return nil, false } u, ok := ctx.Value(userKey{}).(*Principal) return u, ok && u != nil } ``` ### `": "`-prefixed, plugin-and-identifier-naming errors **Source:** every package in this repo (`surf: middleware %q already registered by %s`, `bouncer: jwt secret is empty`, `compass: config directory is empty`). **Apply to:** every new error path this phase introduces (guard registry, limiter bucket registration, raw-group violations, fetch-guard failure reasons). Keep the package-name prefix and, where a plugin is implicated, name it explicitly (never a bare "registration failed"). ### Never-nil slice on JSON output **Source:** `fonoteka.go` `classes/serialize.go` `SerializeAlbum` (lines 15-22). **Apply to:** Any handler-built DTO with a slice field (D-17's "never-nil slice helper"); the `GenreList{Data: rows}` shape in `controllers/genre_controller.go` (line 101, `rows := make([]GenreAggregate, 0)`) is a second live example of the same discipline applied at the query layer instead of the serializer layer — both are valid places to guarantee non-nil, pick whichever is closer to the data source per DTO. ## No Analog Found | File | Role | Data Flow | Reason | |------|------|-----------|--------| | `fetchguard/fetch.go` (new package) | service | streaming | No outbound-HTTP-client code exists anywhere in either repo yet; RESEARCH.md's own Code Examples section (dial-time SSRF guard) is the concrete starting point instead of an in-repo analog — treat it as the implementation skeleton, not a summary to re-derive from scratch. | | `surf/limiter.go` fixed-window algorithm internals (Hit/TooManyAttempts/AvailableIn control flow) | service | event-driven | No rate limiter exists in this codebase yet. RESEARCH.md's Code Examples section ports the control flow directly from Laravel's `Illuminate\Cache\RateLimiter` vendor source (read directly this session) — use that as the spec, `compass/config.go`'s mutex-guarded-struct shape only for the Go idiom of wrapping shared state safely. | | OpenAPI generation wiring (`summer openapi:generate` or equivalent + swag annotations) | config/build-tooling | batch | No `bonfire.Command` in this repo currently wraps an external code-generation tool; `surf/serve.go`'s `bonfire.Command{Name, Flags, Run}` shape (full file) is the closest structural analog for *how to define the command*, but the swag-invocation logic itself has no precedent in-repo. | ## Metadata **Analog search scope:** `bouncer/`, `surf/`, `pact/`, `compass/`, `bonfire/`, `lagoon/`, `party/`, `backpack/` in `summercms.go`; `plugins/golem15/fonoteka/{controllers,middleware,classes,models}/`, `routes.go`, `plugin.go` in `fonoteka.go`; `parity/manifest.yaml` structure checked for route/auth_group shape. **Files scanned:** 22 read in full or targeted sections (surf/router.go, surf/serve.go, surf/params.go, surf/router_test.go, bouncer/jwt.go, bouncer/context.go, bouncer/jwt_test.go, pact/capabilities.go, compass/config.go, bonfire/command.go, party/registry.go, fonoteka.go/routes.go, plugin.go, controllers/genre_controller.go, middleware/must_change_password.go, classes/serialize.go, models/api_token.go, plus directory listings of classes/, controllers/, middleware/, parity/). **Pattern extraction date:** 2026-09-19