637 lines
35 KiB
Markdown
637 lines
35 KiB
Markdown
# 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):
|
|
```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: <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`:**
|
|
```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:<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: <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
|
|
}
|
|
```
|
|
|
|
### `"<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
|