Files
summercms/.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-PATTERNS.md
2026-09-19 16:58:48 +02:00

35 KiB

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

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

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

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

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: <what> %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:

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

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

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

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

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.

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

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

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

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.

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

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:

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

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

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:

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

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.

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

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.

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
}

"<package>: <message>"-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