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

33 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, user_setup, must_haves
phase plan type wave depends_on files_modified autonomous requirements user_setup must_haves
06-http-routing-auth-groups-and-rate-limiting 03 execute 3
06-02
summercms.go/pact/capabilities.go
summercms.go/surf/router.go
summercms.go/surf/router_test.go
summercms.go/surf/routetable.go
summercms.go/surf/routetable_test.go
summercms.go/surf/routelist_command.go
summercms.go/surf/cors.go
summercms.go/surf/cors_test.go
summercms.go/surf/bodylimit.go
summercms.go/surf/bodylimit_test.go
summercms.go/wire/response.go
summercms.go/wire/response_test.go
summercms.go/internal/build/build.go
summercms.go/internal/build/build_test.go
fonoteka.go/plugins/golem15/fonoteka/routes.go
fonoteka.go/plugins/golem15/fonoteka/plugin.go
fonoteka.go/plugins/golem15/fonoteka/controllers/genre_controller.go
fonoteka.go/config/http.yaml
fonoteka.go/scripts/check-openapi.sh
fonoteka.go/docs/openapi.json
false
HTTP-06
HTTP-08
HTTP-09
service why dashboard_config
production-host http.body_limits config keys need the real client_max_body_size/post_max_size/upload_max_filesize values from the production nginx vhost and php.ini, which are not in any repo (06-RESEARCH.md Assumption A2 / Open Question 2)
task location
Read client_max_body_size (nginx), post_max_size and upload_max_filesize (php.ini) off the production host and report the three numbers production host, operator-managed nginx vhost and php.ini, outside version control per docs/deploy/plytarium.com.md
truths artifacts key_links
A group flagged Raw refuses any middleware tagged house-envelope/error at registration time, failing boot rather than silently mounting it (D-16)
A panic inside a raw-group route returns a bare 500 with no JSON body, while a panic in any other route still returns the house {"error":true,"message":"Internal server error"} body (D-16)
The .well-known/oauth-authorization-server and oauth/mcp/* group is declared raw with fonoteka-oauth-token and fonoteka-oauth-register attached to its two future routes, and carries no handlers yet (D-16)
A route table (method, pattern, plugin, middleware chain, raw flag) is exposed and used both by a test and by a route:list CLI command (D-16)
No route under /api/v1/fonoteka carries jwt.auth in its middleware chain, and no route under /_fonoteka/api/v1 carries inv_token or an inv.scope: entry, verified by inspecting the route table (D-16, completes T-06-02 from 06-01)
wire.WriteJSON reproduces genre_controller.go's writeJSON byte-for-byte (SetEscapeHTML(false), trailing-newline trim); wire.Time marshals as YYYY-MM-DDTHH:MM:SS+00:00, never Z; a never-nil wire.Slice helper exists (D-17)
GET /_fonoteka/api/v1/genres carries no CORS headers on any response; GET /api/v1/fonoteka/genres carries Access-Control-Allow-Origin: * on every response, matching config/cors.php's paths list excluding _fonoteka/api/* (D-18, Pitfall 10)
http.body_limits config keys exist with clearly-marked INTERIM defaults; a blocking checkpoint records the operator-reported production values before this plan is considered complete (D-18, user-resolved Open Question 1)
swag-annotated ListGenres produces a committed OpenAPI document that openapi-typescript converts into valid TypeScript with zero errors (D-08/HTTP-08)
path provides
summercms.go/surf/routetable.go RouteInfo{Method,Pattern,PluginID,Middleware,Raw} and Router.Routes()
path provides
summercms.go/wire/response.go WriteJSON, WriteOpaque500, Time, TriBool, Slice[T]
path provides
summercms.go/surf/cors.go Path-scoped, config-driven CORS matching config/cors.php's paths/methods/origins/headers/max_age/credentials
path provides
fonoteka.go/docs/openapi.json Generated OpenAPI document from swag annotations on the real genres handler
from to via pattern
fonoteka.go/plugins/golem15/fonoteka/routes.go summercms.go/surf/router.go the oauth group is declared via GroupRaw, not Group GroupRaw(
from to via pattern
summercms.go/surf/routelist_command.go summercms.go/surf/routetable.go the route:list command calls Router.Routes() and renders it as a table .Routes()
from to via pattern
fonoteka.go/plugins/golem15/fonoteka/controllers/genre_controller.go summercms.go/wire/response.go GenreList/writeJSON usage is replaced by or delegates to wire helpers wire.(WriteJSON|Time|Slice)
Ship the structural guarantees HTTP-06/HTTP-08/HTTP-09 require and that no later phase can retrofit without a breaking change: a `Raw` group flag enforced at registration (not by convention), a route table + `route:list` CLI command that makes group mutual-exclusivity and the OAuth exemption independently verifiable, promoted response-convention helpers (`wire` package), path-scoped config-driven CORS matching `config/cors.php` exactly, per-group JSON body limits, and the swag/`openapi-typescript` pipeline on the one real handler this phase has (`ListGenres`).

Purpose: this plan is the "contract surface" of the phase -- everything a future handler or a future auth guard (OAuth, Phase 8) must be structurally prevented from getting wrong, proven by inspection rather than trusted by convention. Output: Router.GroupRaw, Router.Routes(), surf.RouteListCommand, wire.WriteJSON/Time/TriBool/Slice, path-scoped surf.cors, per-group body limits, the oauth group declared raw, a committed OpenAPI document, and a resolved (not guessed) production body-size limit.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>

@.planning/PROJECT.md @.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-CONTEXT.md @.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-RESEARCH.md @.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-PATTERNS.md @.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-01-SUMMARY.md @.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-02-SUMMARY.md Extended summercms.go/pact/capabilities.go (Router interface):

type Router interface { Group(prefix string, middleware []string, fn func(Router)) GroupRaw(prefix string, middleware []string, fn func(Router)) Get(path string, handler http.HandlerFunc, middleware ...string) Post(path string, handler http.HandlerFunc, middleware ...string) Put(path string, handler http.HandlerFunc, middleware ...string) Patch(path string, handler http.HandlerFunc, middleware ...string) Delete(path string, handler http.HandlerFunc, middleware ...string) Where(param, pattern string) WhereIn(param string, values ...string) }

New file summercms.go/surf/routetable.go:

package surf

type RouteInfo struct { Method string Pattern string PluginID string Middleware []string Raw bool }

// Routes returns a defensive copy of every registered route, post-Assemble. func (r *Router) Routes() []RouteInfo

New file summercms.go/surf/routelist_command.go:

package surf

// RouteListCommand mirrors ServeCommand's shape: builds the router the same // way (party.Activate has already run; this command receives app/plugins), // but renders Routes() as a table instead of serving. func RouteListCommand(app *backpack.App, plugins []party.Plugin) bonfire.Command

Router internals (summercms.go/surf/router.go) needed to support the above:

  • route struct gains a raw bool field, set from the declaring Group/GroupRaw at add() time (thread it exactly like groupMW already threads through Group/Get/Post/etc).
  • Group gains a raw bool field, inherited (once true, stays true through nested Group() calls -- a raw group cannot spawn a non-raw child).
  • RegisterMiddleware and RegisterMiddlewareFactory both gain a variant that tags an entry as house-envelope/error (see Task 1 action for the exact shape); wrap() refuses a raw route referencing a house-tagged name at Assemble time (registration time), returning a "surf: raw group cannot use house-envelope middleware %q (plugin %q)" error -- not a runtime check.
  • compile()/wrap() recovery moves from one blanket recoverJSON(cors(...)) wrap in compile() to a per-route choice inside wrap(): raw routes get a bare-500 recoverBare; non-raw routes keep recoverJSON's existing {"error":true,"message":"Internal server error"} body.
  • Assemble no longer needs a package-level BuildRouter split: RouteListCommand can call the same Assemble(app, plugins) as ServeCommand and then call .(*Router) via a small unexported adapter, OR Assemble is refactored into BuildRouter(app, plugins) (*Router, error) + Assemble = BuildRouter+compile -- choose the refactor (BuildRouter) since it avoids any type assertion on the returned http.Handler and is the cleaner seam for route:list.

New file summercms.go/wire/response.go:

package wire

// WriteJSON is byte-identical to genre_controller.go's private writeJSON // (SetEscapeHTML(false), trailing-newline trim via bytes.TrimSuffix). func WriteJSON(w http.ResponseWriter, status int, v any) func WriteOpaque500(w http.ResponseWriter)

// Time marshals as Carbon's +00:00 form (2006-01-02T15:04:05+00:00), never // Go's default "Z". Unmarshal accepts both +00:00 and Z for read paths. type Time struct{ time.Time } func (t Time) MarshalJSON() ([]byte, error) func (t *Time) UnmarshalJSON(b []byte) error

// TriBool keeps PHP's nullable-boolean tri-state: Valid=false marshals null. type TriBool struct{ Valid, Value bool } func (b TriBool) MarshalJSON() ([]byte, error)

// Slice guarantees a never-nil JSON array. func Slice[T any](s []T) []T

Task 1 (summercms.go): Raw group enforcement, route table, and the route:list command summercms.go/pact/capabilities.go, summercms.go/surf/router.go, summercms.go/surf/router_test.go, summercms.go/surf/routetable.go, summercms.go/surf/routetable_test.go, summercms.go/surf/routelist_command.go, summercms.go/internal/build/build.go, summercms.go/internal/build/build_test.go summercms.go/surf/router.go (full, post-06-02 -- route/Group structs, add/compile/wrap, RegisterMiddleware, Assemble, recoverJSON, cors) summercms.go/surf/serve.go (full -- ServeCommand's bonfire.Command shape to mirror for RouteListCommand) summercms.go/bonfire/command.go (Command/Output shapes; bonfire/widgets.go's Table(headers []string, rows [][]string) signature) summercms.go/lagoon/commands.go (RuntimeCommands -- an existing bonfire.Command-returning function to mirror for style, not for content) summercms.go/internal/build/build.go lines 80-127 (generateMain -- exact lines emitting "commands = append(commands, surf.ServeCommand(app, plugins))"; add the route:list line directly after it) summercms.go/internal/build/build_test.go (existing assertions on generated main.go content to extend) .planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-RESEARCH.md Pattern 3 (raw group structural exemption, lines 303-321) In pact/capabilities.go, add GroupRaw(prefix string, middleware []string, fn func(Router)) to the Router interface, same signature shape as Group.
In surf/router.go: add raw bool to both Group and route. Add GroupRaw on *Router and *Group mirroring Group's existing body exactly, except the constructed child Group has raw: true; when Group() is called on an ALREADY-raw Group (g.raw == true), the child it constructs must also be raw:true (sticky inheritance -- a raw group cannot spawn a non-raw child via a later Group() call). Thread raw through add()'s signature (alongside pluginID/prefix/groupMW/method/path/handler/extra) so route.raw is set from whichever Group/GroupRaw declared it.

Add a houseTagged bool field to namedMiddleware and namedMiddlewareFactory. Add RegisterHouseMiddleware(pluginID, name string, fn pact.Middleware) error and RegisterHouseMiddlewareFactory(pluginID, name string, fn func(string) pact.Middleware) error, each calling the existing Register*/RegisterMiddlewareFactory internals but setting houseTagged: true on the stored entry (do not duplicate the dup-check logic -- factor a shared unexported registerNamed(pluginID, name string, tagged bool, ...) if that keeps both call sites DRY, or two thin wrappers if simpler; either is acceptable as long as the dup/empty-name checks are not duplicated verbatim). In fonoteka.go's plugin.go (see Task 3 of this plan for the actual call-site change), "inv.must-change-password" switches from RegisterMiddleware to RegisterHouseMiddleware.

In wrap()'s middleware-resolution loop: after resolving a name to either a namedMiddleware or a factory-built middleware, check `if rt.raw && resolvedHouseTagged { return nil, fmt.Errorf("surf: raw group cannot use house-envelope middleware %q (plugin %q)", name, rt.pluginID) }` -- this makes the refusal a registration-time (Assemble-time) failure, since Assemble already calls r.wrap(rt) once per route specifically to surface these errors before compile().

Change wrap()'s outer recovery: instead of compile() wrapping the whole mux in one recoverJSON(cors(...)), move recovery INTO wrap() per-route: `if rt.raw { h = recoverBare(h) } else { h = recoverJSON(h) }` as the outermost wrap (applied after the locale() wrap, i.e. truly outermost). Add recoverBare(next http.Handler) http.Handler mirroring recoverJSON's shape but on panic writing only w.WriteHeader(http.StatusInternalServerError) with NO body and NO Content-Type header set (D-16: "bare 500, no house JSON body"). compile()'s final return simplifies to just the CORS wrapper around mux (CORS itself becomes path-scoped in Task 3 of this plan -- for THIS task, compile() can keep calling the existing blanket cors(r.origins, mux) unchanged; Task 3 replaces it).

Create routetable.go: RouteInfo exactly as specified in the interfaces block; (r *Router) Routes() []RouteInfo iterating r.routes and returning a defensive copy (new slice, new []string per entry -- do not alias r.routes[i].middleware).

Refactor Assemble into BuildRouter(app *backpack.App, plugins []party.Plugin) (*Router, error) containing everything Assemble currently does EXCEPT the final r.compile() call (return r, nil instead); Assemble(app, plugins) becomes `r, err := BuildRouter(app, plugins); if err != nil { return nil, err }; return r.compile()`. This lets RouteListCommand call BuildRouter directly and read .Routes() without compiling/serving.

Create routelist_command.go: RouteListCommand(app, plugins) bonfire.Command{Name: "route:list", Description: "List registered HTTP routes", Run: func(ctx, in, out) error { r, err := BuildRouter(app, plugins); if err != nil { return err }; rows := make([][]string, 0, len(r.Routes())); for _, rt := range r.Routes() { rows = append(rows, []string{rt.Method, rt.Pattern, rt.PluginID, strings.Join(rt.Middleware, ","), strconv.FormatBool(rt.Raw)}) }; out.Table([]string{"Method","Pattern","Plugin","Middleware","Raw"}, rows); return nil }}.

In internal/build/build.go's generateMain, add a line `b.WriteString("\tcommands = append(commands, surf.RouteListCommand(app, plugins))\n")` directly after the existing `surf.ServeCommand` append line. Update build_test.go's existing content-assertion test to also assert the generated main.go contains "surf.RouteListCommand".
cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && go vet ./... && go test ./surf/... -run TestRawGroup -short && go test ./surf/... -run TestRouteTable -short && go test ./internal/build/... -short - A test registers a house-tagged middleware and a raw group referencing it by name; asserts Assemble/BuildRouter returns an error naming the middleware and the plugin. - A test registers a raw group with a panicking handler; asserts the response is status 500 with an EMPTY body and no Content-Type header; a parallel non-raw panicking route still returns the existing {"error":true,"message":"Internal server error"} JSON body. - A test asserts Router.Routes() reflects Raw:true only for routes declared inside GroupRaw, and that a route declared via a plain Group() nested inside a GroupRaw() is also Raw:true (sticky inheritance). - go test ./internal/build/... confirms the generated main.go template contains both "surf.ServeCommand" and "surf.RouteListCommand". Raw-group refusal is a registration-time failure, not a runtime check; the route table and route:list command exist and are independently testable. Task 2 (summercms.go, fonoteka.go): Response-convention wire package and the swag/openapi-typescript pipeline summercms.go/wire/response.go, summercms.go/wire/response_test.go, fonoteka.go/plugins/golem15/fonoteka/controllers/genre_controller.go, fonoteka.go/scripts/check-openapi.sh, fonoteka.go/docs/openapi.json fonoteka.go/plugins/golem15/fonoteka/controllers/genre_controller.go (full -- writeJSON/writeOpaque500 to promote verbatim, GenreList/GenreAggregate to annotate) summercms.go/tide/normalize.go lines 1-20 (the carbonOffsetRe regex: ^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+00:00$ -- the exact target format, no fractional seconds, literal +00:00 not Z) .planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-RESEARCH.md Standard Stack (swaggo/swag v1.16.6, openapi-typescript 7.13.0) and Installation section Create wire/response.go: WriteJSON and WriteOpaque500, copied verbatim in behavior from genre_controller.go's private writeJSON/writeOpaque500 (same bytes.Buffer + json.Encoder with SetEscapeHTML(false) + bytes.TrimSuffix(buf.Bytes(), []byte("\n")) trick, same {"error":true,"message":"Internal server error"} body for WriteOpaque500). Time struct{ time.Time } with MarshalJSON formatting via t.UTC().Format("2006-01-02T15:04:05") + "+00:00" (do NOT use Go's Z07:00 verb, which emits "Z" for UTC -- must literally match tide/normalize.go's carbonOffsetRe) and UnmarshalJSON accepting a quoted RFC3339-ish string with either +00:00 or Z (use time.Parse with two candidate layouts, first match wins). TriBool{Valid, Value bool} with MarshalJSON returning "null" when !Valid, else "true"/"false" as bare JSON booleans (not quoted). Slice[T any](s []T) []T returning s unchanged if non-nil, else []T{} (mirrors classes/serialize.go's existing inline pattern -- this is the promoted, reusable form of it).
In genre_controller.go, replace the private writeJSON/writeOpaque500 function bodies with thin delegations to wire.WriteJSON/wire.WriteOpaque500 (keep the local function names and call sites unchanged so no other file in this package needs to change -- only the two function bodies become one-line delegations, proving the promotion without a wider refactor this phase). Add swag doc comments directly above the ListGenres function: `// @Summary List genres` / `// @Description Returns the global genre pool with tenant-scoped album counts` / `// @Tags genres` / `// @Produce json` / `// @Param non_empty query string false "1 to only return genres with albums"` / `// @Success 200 {object} GenreList` / `// @Router /_fonoteka/api/v1/genres [get]` (swag's comment-annotation syntax, scanning this handler's existing signature -- no signature change).

Create scripts/check-openapi.sh (fonoteka.go/scripts/, executable): runs `go run github.com/swaggo/swag/cmd/swag@v1.16.6 init --generalInfo plugins/golem15/fonoteka/controllers/genre_controller.go --output docs --parseDependency` (adjust flags to this repo's actual layout after a first local run) to produce docs/openapi.json (swag's default is swagger.json/yaml -- pin the output filename explicitly via swag's -o/--output and --outputTypes json flags so the committed artifact is deterministic), then runs `npx --yes openapi-typescript@7.13.0 docs/openapi.json -o /dev/null` (validity check only -- Phase 10 owns wiring real output into the admin SPA) and fails the script (non-zero exit) if either command errors, and a third check: `git diff --exit-code docs/openapi.json` style drift check IF run in CI (document this as the drift-check step; for local/plan execution, running the script once and committing docs/openapi.json is sufficient).

Run the script once locally, commit the resulting docs/openapi.json.
cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && go vet ./... && go test ./wire/... -short && cd ../fonoteka.go && bash scripts/check-openapi.sh - wire.Time{}.MarshalJSON on a known UTC time produces a string matching tide/normalize.go's carbonOffsetRe exactly (test asserts via that same regex, imported or copied into wire/response_test.go). - wire.TriBool{Valid:false} marshals to null; {Valid:true,Value:false} marshals to false (bare, not "false" string). - wire.Slice(nil) returns a non-nil empty slice that marshals to []. - fonoteka.go/scripts/check-openapi.sh exits 0 and fonoteka.go/docs/openapi.json exists and is valid JSON containing a /_fonoteka/api/v1/genres path. wire package exists and is unit-tested independent of any handler; genre_controller.go delegates to it; a real, committed OpenAPI document is generated from a real handler's annotations and validated by openapi-typescript. Task 3 (fonoteka.go): Path-scoped CORS, per-group body limits, the raw OAuth group, and the production body-limit checkpoint summercms.go/surf/cors.go, summercms.go/surf/cors_test.go, summercms.go/surf/bodylimit.go, summercms.go/surf/bodylimit_test.go, fonoteka.go/plugins/golem15/fonoteka/routes.go, fonoteka.go/plugins/golem15/fonoteka/plugin.go, fonoteka.go/config/http.yaml summercms.go/surf/router.go (post-Task-1 of this plan -- compile()'s current blanket cors(r.origins, mux) call to replace) summercms.go/compass/config.go (LoadSection with koanf tags -- the mechanism for CORSConfig) /media/nvme/dev/golem15/fonoteka/config/cors.php (full, already read -- exact paths/methods/origins/headers/max_age/credentials values) /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/routes.php lines 521-567 (the oauth group: bindings only, two POST routes each with one throttle, no jwt.auth/inv.scope, handlers arrive Phase 8) fonoteka.go/parity/fixtures/routes/GET__api_v1_fonoteka_genres_personal_token.yaml (confirms wildcard CORS on the personal-token group) .planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-RESEARCH.md Assumption A2 / Open Question 2 (body-size limits not in any repo) Create surf/cors.go: CORSConfig struct with koanf tags matching cors.php's keys exactly (Paths []string `koanf:"paths"`, AllowedMethods []string `koanf:"allowed_methods"`, AllowedOrigins []string `koanf:"allowed_origins"`, AllowedOriginsPatterns []string `koanf:"allowed_origins_patterns"`, AllowedHeaders []string `koanf:"allowed_headers"`, ExposedHeaders []string `koanf:"exposed_headers"`, MaxAge int `koanf:"max_age"`, SupportsCredentials bool `koanf:"supports_credentials"`); LoadCORSConfig(cfg *compass.Config) (CORSConfig, error) calling cfg.LoadSection("http.cors", &out). A pathScopedCORS(cfg CORSConfig, next http.Handler) http.Handler replacing the existing blanket cors() call in compile(): for each request, check r.URL.Path (with leading slash stripped) against every glob in cfg.Paths using path.Match (Laravel's glob syntax `api/*` maps directly to Go's path.Match "api/*" against the leading-slash-stripped path); if no glob matches, call next.ServeHTTP with NO CORS headers set (this is what makes /_fonoteka/api/* get nothing, since it is not in the paths list); if a glob matches, apply the existing header-setting logic (Origin allow-list or "*", Vary, Allow-Headers, Allow-Methods) using cfg's fields instead of the hardcoded strings currently in cors(), plus Access-Control-Max-Age when cfg.MaxAge > 0 and Access-Control-Allow-Credentials: true when cfg.SupportsCredentials, and still short-circuit OPTIONS preflight with 204 exactly as today. In router.go's compile(), replace the blanket `cors(r.origins, mux)` call with `pathScopedCORS(corsCfg, mux)` where corsCfg is loaded once in BuildRouter via LoadCORSConfig(app.Config) and threaded through compile() (add a corsCfg field to Router, set in BuildRouter before compile is reached).
Create surf/bodylimit.go: two config keys read once in BuildRouter -- http.body_limits.default_bytes and http.body_limits.upload_bytes (both int64 via a small helper since compass.Config.Int returns int; convert). Wrap every non-raw route's handler (innermost, next to constrain()) with http.MaxBytesReader(w, r.Body, defaultBytes) via a bodyLimit(defaultBytes int64) middleware applied unconditionally in wrap() (raw routes are exempt -- RFC endpoints like /oauth/mcp/token have their own well-known limits and must not gain a house-specific cap). Add an opt-in named middleware factory "body.limit" (registered by the router itself, like "throttle") whose param is a byte count string, letting a future upload route request a larger cap by listing "body.limit:20971520" (20 MiB) etc. explicitly in its middleware list, overriding the default for that one route (apply it as the innermost wrap so it supersedes the blanket default-bytes reader for that route only).

In fonoteka.go/config/http.yaml, add: cors: paths: ["api/*", "_user/api/*", "_journal/api/*", "_feedback/api/*", "oauth/mcp/*"], allowed_methods: ["*"], allowed_origins: ["*"], allowed_origins_patterns: [], allowed_headers: ["*"], exposed_headers: [], max_age: 0, supports_credentials: false (byte-for-byte from config/cors.php); body_limits: default_bytes: 8388608 (8 MiB, PHP's stock post_max_size, marked with a comment "INTERIM default per 06-RESEARCH.md Assumption A2 -- replace with the real production client_max_body_size/post_max_size once read off the host"), upload_bytes: 2097152 (2 MiB, PHP's stock upload_max_filesize, same INTERIM comment).

In fonoteka.go/plugins/golem15/fonoteka/plugin.go, switch the "inv.must-change-password" registration from RegisterMiddleware to RegisterHouseMiddleware (Task 1's new capability) -- this is the concrete proof case for D-16's registration-time refusal (a raw group referencing "inv.must-change-password" must now fail boot).

In fonoteka.go/plugins/golem15/fonoteka/routes.go, add the oauth group via r.GroupRaw("/", surf.Use(), func(g pact.Router) {}) -- bindings has no Go equivalent (no route-model binding concept), so the middleware list is empty; the group exists purely to prove GroupRaw's structural behavior at this prefix and to be the landing spot for Phase 8's two real routes, each of which will individually add "throttle:fonoteka-oauth-token"/"throttle:fonoteka-oauth-register" per-route (not group-level, matching routes.php's actual per-route ->middleware() calls at lines 561/565) -- do not attach the throttle names to the empty GroupRaw call itself, since PHP attaches them per-route, not per-group, and there is no route to attach them to yet.
cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && go vet ./... && go test ./surf/... -run TestCORS -short && go test ./surf/... -run TestBodyLimit -short - A test against the assembled fonoteka router asserts a request to /_fonoteka/api/v1/genres carries NO Access-Control-Allow-Origin header, and a request to /api/v1/fonoteka/genres carries Access-Control-Allow-Origin: * (Pitfall 10, matching the recorded parity fixture). - A test asserts a request body larger than http.body_limits.default_bytes on a non-raw route fails with the standard http.MaxBytesReader error surfaced as a 4xx/error response (exact status code documented in the test, matching whatever the handler's own body-read error path already returns via the existing recoverJSON/error handling -- if no handler currently reads a body, test MaxBytesReader directly against a framework fixture route). - A test asserts the oauth GroupRaw declaration has Raw:true in the route table (even with zero routes, assert via boot-time introspection of the Group's raw flag if Routes() has nothing to show for an empty group -- prefer adding one framework-fixture raw route inside a TEST-only GroupRaw call, not inside production routes.go, to get a concrete RouteInfo{Raw:true} entry to assert against). - A test asserts registering "inv.must-change-password" (now house-tagged) inside a raw group fails Assemble/BuildRouter with an error naming the middleware. This task is a blocking checkpoint per the user-resolved Open Question 1 (body-size limits). Before this plan is considered complete: 1. Have the operator read the production nginx vhost's `client_max_body_size` and php.ini's `post_max_size`/`upload_max_filesize` off the production host (docs/deploy/plytarium.com.md's operator-managed vhost, not in any repo). 2. Replace the INTERIM `default_bytes`/`upload_bytes` values in fonoteka.go/config/http.yaml with the real numbers (converted to bytes). 3. Update the two INTERIM comments to record the reported values and the date they were confirmed. 4. Re-run the body-limit tests to confirm they still pass against the new numbers (they assert against config-loaded values, not hardcoded ones, so no test code changes should be needed -- only the config numbers and their comments). Type "approved" once the real production body-size numbers are recorded in fonoteka.go/config/http.yaml, or describe what changed if the numbers differ from the INTERIM defaults. CORS is path-scoped and matches config/cors.php exactly; body limits are enforced per group with the real (operator-confirmed) production numbers, not a guess; the oauth group is structurally raw with zero handlers.

<threat_model>

Trust Boundaries

Boundary Description
raw group -> house middleware a misconfigured raw group must fail boot, not silently mount an envelope/error middleware onto an RFC-shaped surface
any request body -> handler an unbounded request body is a resource-exhaustion vector regardless of auth group
CORS config -> browser an overly permissive path-glob match would leak the JWT group's same-origin-only posture to cross-origin callers

STRIDE Threat Register

Threat ID Category Component Disposition Mitigation Plan
T-06-10 Elevation of Privilege Route table mutual-exclusivity mitigate routetable_test.go asserts, over the FULL assembled route table, that no /api/v1/fonoteka route carries jwt.auth and no /_fonoteka/api/v1 route carries inv_token/inv.scope: -- this completes the partial coverage 06-01 accepted (T-06-02)
T-06-11 Tampering Raw-group house-middleware refusal mitigate Registration-time (not request-time) failure; a future PR cannot silently attach an envelope middleware to the OAuth group and have it ship
T-06-12 Denial of Service Unbounded request body on a non-raw route mitigate http.MaxBytesReader applied unconditionally per non-raw route via the default body limit; raw (RFC) routes rely on their own future handler-level limits, matching PHP's own scoping
T-06-13 Information Disclosure Body-size limits shipped as a guess mitigate INTERIM defaults are clearly commented as unverified; the plan cannot close without the blocking checkpoint recording operator-confirmed production numbers -- never presented as verified when it is not
T-06-SC Tampering npm/go install in scripts/check-openapi.sh (swag, openapi-typescript) accept Both packages are STACK.md-named and pass 06-RESEARCH.md's Package Legitimacy Audit (Approved disposition, no [ASSUMED]/[SUS] verdicts) -- no additional human-verify checkpoint required beyond that prior audit
</threat_model>
cd summercms.go && go vet ./... && go test ./surf/... ./wire/... ./internal/build/... -short cd ../fonoteka.go && go vet ./... && go test ./plugins/golem15/fonoteka/... -short && bash scripts/check-openapi.sh

<success_criteria>

  • Raw groups refuse house-tagged middleware at registration time and recover with a bare 500.
  • The route table and route:list command exist and prove group mutual-exclusivity by inspection.
  • wire package ships the promoted response-convention helpers, unit-tested independently.
  • CORS is path-scoped and matches config/cors.php exactly (JWT group: no headers; token group: wildcard).
  • Body limits are enforced per group with operator-confirmed production numbers, not a guess.
  • The oauth group is declared raw with zero handlers.
  • A real OpenAPI document is generated from swag annotations and validated by openapi-typescript.
  • go vet ./... and go test ./... are green in both repos. </success_criteria>
Create `.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-03-SUMMARY.md` when done