Files
summercms/.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-01-PLAN.md

32 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
06-http-routing-auth-groups-and-rate-limiting 01 execute 1
summercms.go/pact/capabilities.go
summercms.go/surf/router.go
summercms.go/surf/router_test.go
summercms.go/bouncer/guard.go
summercms.go/bouncer/registry.go
summercms.go/bouncer/registry_test.go
summercms.go/bouncer/context.go
summercms.go/bouncer/context_test.go
summercms.go/bouncer/jwt.go
fonoteka.go/plugins/golem15/user/plugin.go
fonoteka.go/plugins/golem15/fonoteka/plugin.go
fonoteka.go/plugins/golem15/fonoteka/models/api_token.go
fonoteka.go/plugins/golem15/fonoteka/classes/auth/token_guard.go
fonoteka.go/plugins/golem15/fonoteka/classes/auth/token_guard_test.go
fonoteka.go/plugins/golem15/fonoteka/middleware/token_scope.go
fonoteka.go/plugins/golem15/fonoteka/middleware/token_scope_test.go
fonoteka.go/plugins/golem15/fonoteka/routes.go
fonoteka.go/plugins/golem15/fonoteka/routes_group_test.go
fonoteka.go/parity/genres_seed_test.go
fonoteka.go/parity/manifest.yaml
true
HTTP-03
HTTP-05
truths artifacts key_links
A JWT-authenticated GET /_fonoteka/api/v1/genres and a personal-token GET /api/v1/fonoteka/genres are served by the literal same controllers.ListGenres(p.app) handler value (D-15 shared-handler proof)
bouncer.User(ctx) resolves identically for a JWT caller and a personal-token caller; only one accessor exists (D-06)
A missing/unknown/malformed inv_ token on the personal-token group returns 401 {"error":"Invalid token"}; a token missing the required scope returns 403 {"error":"Missing required scope: <scope>"} -- never bouncer's {"error":true,"message":...} shape (D-08)
The jwt guard's existing 401 bodies and Middleware(secret, users) behavior are byte-identical to Phase 3 after being re-expressed through the registry (D-10)
Registering two guards under the same name, or referencing an unregistered guard name, fails boot with a message naming both plugins (D-06)
The oauth guard name is not registered by any code in this plan -- grep for Register( calls finds only "jwt" and "inv_token" (D-09)
GET /api/v1/fonoteka/genres personal_token is status: ported in manifest.yaml and passes TestParityCorpus against real Postgres via a seed-hook-inserted token, not a production mint path (D-07)
Parameterized ("name:param") middleware names are resolved by a surf factory mechanism, not a fixed string table, so "inv.scope:write" and a future "throttle:10,1" are written at call sites exactly as PHP's ->middleware() calls (D-05)
path provides
summercms.go/bouncer/registry.go Named Guard registry: Register(pluginID, name string, g any) error, Middleware(name string) (func(http.Handler) http.Handler, error)
path provides
summercms.go/bouncer/guard.go Guard, CredentialGuard, UnauthorizedWriter interfaces
path provides
fonoteka.go/plugins/golem15/fonoteka/classes/auth/token_guard.go TokenGuard: real inv_token verification against models.ApiToken (hash lookup, expiry, revocation, last-used stamp)
path provides
fonoteka.go/plugins/golem15/fonoteka/middleware/token_scope.go InvScope(scope string) pact.Middleware with exact PHP 401/403 bodies
from to via pattern
fonoteka.go/plugins/golem15/fonoteka/routes.go fonoteka.go/plugins/golem15/fonoteka/controllers/genre_controller.go both r.Group calls register controllers.ListGenres(p.app) as the GET handler controllers.ListGenres(p.app)
from to via pattern
fonoteka.go/plugins/golem15/fonoteka/middleware/token_scope.go summercms.go/bouncer/context.go InvScope reads bouncer.User(ctx) and bouncer.Credential(ctx) bouncer.(User|Credential)(r.Context())
from to via pattern
fonoteka.go/plugins/golem15/fonoteka/classes/auth/token_guard.go fonoteka.go/plugins/golem15/fonoteka/models/api_token.go hash lookup against ApiToken.TokenHash, then IsUsable()/HasScope() token_hash = ?|IsUsable()|HasScope(
Turn the Phase 3 JWT-only routing skeleton into a real two-guard auth surface: extend the router to support non-GET verbs and parameterized ("name:param") middleware, build a named Guard registry in `bouncer` that both the existing `jwt` guard and a new real `inv_token` guard resolve through to one `bouncer.User(ctx)` accessor, and prove HTTP-03's core claim -- the same handler serves both the JWT group and the personal-token group -- by mounting `GET genres` under `/_fonoteka/api/v1` (JWT) and `/api/v1/fonoteka` (`inv_token` + `inv.scope:read`).

Purpose: this is the foundational wave every later Phase 6 plan (rate limiting, raw groups, response conventions) builds on -- the router's verb/factory growth and the guard registry are load-bearing seams, not local-only code. Output: pact.Router/surf.Router support Post/Put/Patch/Delete and colon-parameterized middleware names; bouncer.Registry with two real guards; golem15.fonoteka's inv_token guard and inv.scope middleware; genres reachable and parity-green on both auth groups.

<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 New file summercms.go/bouncer/guard.go:

package bouncer

import "net/http"

// Guard resolves the caller's Principal for r, or an error describing why not. type Guard interface { Authenticate(r *http.Request) (*Principal, error) }

// CredentialGuard resolves the Principal AND its underlying credential (e.g. // *models.ApiToken) in one pass -- a DB-backed guard must never verify twice // per request (RESEARCH.md Pitfall 5: last_used_at must stamp once). type CredentialGuard interface { AuthenticateCredential(r *http.Request) (*Principal, any, error) }

// UnauthorizedWriter lets a guard write its own failure response. jwtGuard // implements this (reusing write401's {"error":true,"message":...} shape). // TokenGuard does NOT implement it: PHP's TokenScope, not ApiTokenGuard, owns // the {"error":"Invalid token"} 401 body (D-08) -- Registry.Middleware must // pass an unauthenticated request through untouched when a guard has no // UnauthorizedWriter, leaving denial to downstream middleware. type UnauthorizedWriter interface { WriteUnauthorized(w http.ResponseWriter, err error) }

New file summercms.go/bouncer/registry.go:

package bouncer

import "net/http"

type Registry struct{ /* unexported: map[string]namedGuard */ }

func NewRegistry() *Registry

// Register stores g under name. g must implement Guard or CredentialGuard. // Empty name, nil g, a type implementing neither, or a duplicate name all // fail with a "bouncer: ..." error naming pluginID and name. func (reg *Registry) Register(pluginID, name string, g any) error

// Middleware derives an http middleware from a registered guard. Unknown // names fail (fail boot, mirrors surf.RegisterMiddleware's contract). // On Authenticate/AuthenticateCredential success: WithUser (+WithCredential // if a credential was returned) then next.ServeHTTP. // On failure: if the guard implements UnauthorizedWriter, it writes the // response and the chain stops; otherwise next.ServeHTTP runs unauthenticated. func (reg *Registry) Middleware(name string) (func(http.Handler) http.Handler, error)

Extended summercms.go/bouncer/context.go (add alongside the existing userKey/WithUser/User):

type credentialKey struct{}

func WithCredential(ctx context.Context, cred any) context.Context func Credential(ctx context.Context) (any, bool)

New guard constructor in summercms.go/bouncer/jwt.go (adapter over the UNCHANGED existing helpers -- do not edit bearerToken/Verify/write401/msgUserNotFound bodies):

// NewJWTGuard adapts the existing bearerToken -> Verify -> users.FindByID // chain (identical to Middleware's body) into a Guard + UnauthorizedWriter, // so Registry.Middleware("jwt") is byte-identical to bouncer.Middleware. func NewJWTGuard(secret string, users UserProvider) Guard

Extended summercms.go/pact/capabilities.go (Router interface):

type Router interface { Group(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) }

Extended summercms.go/surf/router.go:

// RegisterMiddlewareFactory stores a parameterized middleware. At wrap time, // a route middleware name not found in r.named is split on the first ':' // (strings.Cut); if the base name matches a registered factory, fn(param) // builds the pact.Middleware for that one route. Duplicate factory names // fail exactly like RegisterMiddleware. func (r *Router) RegisterMiddlewareFactory(pluginID, name string, fn func(param string) pact.Middleware) error

Extended summercms.go/pact/capabilities.go (new capability, collected in surf.Assemble in the same loop as HasMiddleware):

type HasMiddlewareFactories interface { MiddlewareFactories() map[string]func(param string) Middleware }

Task 1 (summercms.go): Router verb growth, parameterized-middleware factories, and the bouncer Guard registry summercms.go/pact/capabilities.go, summercms.go/surf/router.go, summercms.go/surf/router_test.go, summercms.go/bouncer/guard.go, summercms.go/bouncer/registry.go, summercms.go/bouncer/registry_test.go, summercms.go/bouncer/context.go, summercms.go/bouncer/context_test.go, summercms.go/bouncer/jwt.go summercms.go/pact/capabilities.go (full -- current Router interface, HasMiddleware) summercms.go/surf/router.go (full -- route struct, add/compile/wrap, RegisterMiddleware, Assemble) summercms.go/surf/router_test.go (full -- existing test shapes to extend, esp. TestMissingMiddlewareNamesPluginAndName) summercms.go/bouncer/jwt.go (full -- bearerToken, Verify, write401, mapJWTError, msgUserNotFound: reuse verbatim, do not rewrite) summercms.go/bouncer/context.go (full -- WithUser/User shape to mirror for WithCredential/Credential) .planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-RESEARCH.md (Pitfall 9: router is GET-only, exact lines to generalize; Pattern 1) In pact/capabilities.go: add Post, Put, Patch, Delete to the Router interface with the exact same signature shape as Get (path string, handler http.HandlerFunc, middleware ...string). Add HasMiddlewareFactories interface { MiddlewareFactories() map[string]func(param string) Middleware } alongside the existing HasMiddleware (bare Middleware, not pact.Middleware -- this interface is declared inside package pact itself, where Middleware is already the unqualified local type).
In surf/router.go: thread a method string parameter through add(pluginID, prefix string, groupMW []string, method, path string, handler http.HandlerFunc, extra []string); change the duplicate-detection key from the hardcoded "GET " + full to method + " " + full; change route{method: "GET", ...} to use the passed method. Add Post/Put/Patch/Delete on both *Router and *Group, each calling add with its own HTTP method string, mirroring Get's and Group.Get's existing nil-guard shape exactly. In compile(), change mux.Handle("GET "+rt.path, h) to mux.Handle(rt.method+" "+rt.path, h).

Add a factories map[string]namedMiddlewareFactory field to Router (initialize in New), a namedMiddlewareFactory{pluginID string; fn func(param string) pact.Middleware} type, and RegisterMiddlewareFactory(pluginID, name string, fn func(param string) pact.Middleware) error mirroring RegisterMiddleware's nil/empty/duplicate checks and "surf: middleware factory %q already registered by %s" error shape. In wrap()'s middleware-resolution loop, when r.named[name] misses, call strings.Cut(name, ":"); if hasParam and r.factories[base] exists, build h = r.factories[base].fn(param)(h) and continue the loop instead of falling through to the existing "unknown middleware" error (only fall through to that error when neither a named middleware nor a factory match). In Assemble(), add a second loop (after the existing HasMiddleware loop, before the Routes loop) that type-asserts each plugin against pact.HasMiddlewareFactories and calls r.RegisterMiddlewareFactory(p.ID(), name, fn) for each entry.

Create bouncer/guard.go with the Guard, CredentialGuard, UnauthorizedWriter interfaces exactly as specified in the interfaces block above.

Create bouncer/registry.go with Registry, NewRegistry(), Register(pluginID, name string, g any) error (validate g is non-nil and implements Guard or CredentialGuard via a type switch; fail with "bouncer: plugin %q registered guard %q that implements neither Guard nor CredentialGuard" otherwise; duplicate name fails with "bouncer: guard %q already registered by %s", mirroring RegisterMiddleware's message shape), and Middleware(name string) (func(http.Handler) http.Handler, error) exactly as specified above: prefer CredentialGuard.AuthenticateCredential when the guard implements it, else Guard.Authenticate; on error, call UnauthorizedWriter.WriteUnauthorized if implemented, else pass the request through unauthenticated (no principal attached); on success, WithUser then WithCredential (only if a non-nil credential was returned) before calling next.

Extend bouncer/context.go with credentialKey{} and WithCredential/Credential, an exact structural mirror of userKey{}/WithUser/User (nil-context guard, context.WithValue, type-assert-with-ok).

In bouncer/jwt.go, add NewJWTGuard(secret string, users UserProvider) Guard returning an unexported jwtGuard{secret, users} struct whose Authenticate method runs the IDENTICAL sequence Middleware's closure already runs (bearerToken(r) -> Verify(raw, secret) -> strconv.ParseUint the subject -> users.FindByID -> nil checks) by calling those same existing package-level helpers, returning (*Principal, error) instead of writing a response; its WriteUnauthorized(w, err) method calls the existing write401(w, err.Error()) verbatim. Do NOT change the body of the existing exported Middleware function -- it keeps working unmodified (D-10).
cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && go vet ./... && go test ./surf/... ./bouncer/... ./pact/... -short - surf.Router and surf.Group both expose working Post/Put/Patch/Delete; a new test registers POST /items and GET /items on the same path and asserts both are dispatched independently (no collision, no duplicate-route error). - A new test registers a middleware factory under "inv.scope" and a route using "inv.scope:write"; asserts the factory receives exactly "write" as param and its returned middleware runs. - Registry duplicate/unknown-name tests in bouncer/registry_test.go assert the error string contains both plugin id and guard name (mirroring TestMissingMiddlewareNamesPluginAndName's assertion shape). - A test registers a fake guard implementing only Guard + UnauthorizedWriter, asserts Registry.Middleware writes the guard's own response and never calls next on failure. - A test registers a fake guard implementing only CredentialGuard (no UnauthorizedWriter), asserts next still runs on failure with NO principal attached to the context (soft-fail path), and that a successful AuthenticateCredential attaches both bouncer.User(ctx) and bouncer.Credential(ctx). - bouncer.NewJWTGuard(secret, users) wrapped through Registry.Middleware("jwt") produces byte-identical response bodies/status codes to bouncer.Middleware(secret, users) for the same four failure cases already covered by bouncer/jwt_test.go (missing token, expired, bad signature, unknown user). pact.Router supports all four non-GET verbs; surf resolves "name:param" middleware via registered factories; bouncer.Registry exists with Guard/CredentialGuard/UnauthorizedWriter and is unit-tested in isolation from any real guard. Task 2 (fonoteka.go): Real inv_token guard, inv.scope middleware, and guard registration in both plugins fonoteka.go/plugins/golem15/user/plugin.go, fonoteka.go/plugins/golem15/fonoteka/plugin.go, fonoteka.go/plugins/golem15/fonoteka/models/api_token.go, fonoteka.go/plugins/golem15/fonoteka/classes/auth/token_guard.go, fonoteka.go/plugins/golem15/fonoteka/classes/auth/token_guard_test.go, fonoteka.go/plugins/golem15/fonoteka/middleware/token_scope.go, fonoteka.go/plugins/golem15/fonoteka/middleware/token_scope_test.go - ApiToken.HasScope("read") is true only when "read" is present in Scopes.Get(). - ApiToken.IsUsable() is false when RevokedAt != nil, false when ExpiresAt != nil and in the past, true otherwise. - TokenGuard.AuthenticateCredential returns an error for: no Authorization header, a bearer not prefixed inv_, an unknown hash, a revoked token, an expired token. - TokenGuard.AuthenticateCredential on a valid token returns (*bouncer.Principal{ID: token.UserID, ...}, *models.ApiToken, nil) and stamps LastUsedAt/LastUsedIP exactly once (assert via a row re-read, not a mock). - InvScope("write") on a request with no bouncer.User(ctx) writes 401 {"error":"Invalid token"}. - InvScope("write") on a request with a resolved user but a credential lacking the write scope writes 403 {"error":"Missing required scope: write"}. - InvScope("read") on a request with a read-scoped credential calls next. summercms.go/bouncer/guard.go, summercms.go/bouncer/registry.go, summercms.go/bouncer/context.go (Task 1 output) fonoteka.go/plugins/golem15/fonoteka/models/api_token.go (full -- Fillable/Hidden/fields, Scopes is lagoon.Jsonable[[]string]) fonoteka.go/plugins/golem15/fonoteka/middleware/must_change_password.go (full -- exact same-package middleware shape to mirror) fonoteka.go/plugins/golem15/fonoteka/controllers/genre_controller.go (writeJSON/writeOpaque500 -- do NOT import cross-package; token_scope.go needs its own tiny writer, see action) fonoteka.go/plugins/golem15/user/plugin.go (full -- Boot/Middlewares to restructure) fonoteka.go/plugins/golem15/fonoteka/plugin.go (full -- Boot/Middlewares to extend) /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/middleware/TokenScope.php (exact 401/403 bodies, lines 39-60) /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/auth/ApiTokenGuard.php (verify/stamp sequence, lines 42-63) /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/auth/ApiTokenManager.php (PREFIX = 'inv_', sha256 hash, verify() gate, lines 20-83) /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/models/ApiToken.php (isExpired/isRevoked/isUsable/hasScope, lines 63-77) In models/api_token.go, add two methods: HasScope(scope string) bool returning slices.Contains(t.Scopes.Get(), scope) (import "slices"), and IsUsable() bool returning t.RevokedAt == nil && (t.ExpiresAt == nil || t.ExpiresAt.After(time.Now())). Leave Fillable/Hidden/TableName untouched.
Create classes/auth/token_guard.go (package auth): TokenGuard struct { db *gorm.DB }, NewTokenGuard(db *gorm.DB) *TokenGuard. Implement AuthenticateCredential(r *http.Request) (*bouncer.Principal, any, error): read Authorization header, trim a leading "Bearer " (same convention as PHP's $request->bearerToken()); if empty or not strings.HasPrefix(bearer, "inv_"), return an error (mutual-exclusion gate, mirrors ApiTokenManager::verify's prefix check); compute sha256.Sum256([]byte(bearer)) hex-encoded; SELECT one models.ApiToken WHERE token_hash = ?; if not found or !token.IsUsable(), return an error; stamp last_used_at/last_used_ip with a single UpdateColumns call against models.ApiToken{} filtered by id = ? (derive the IP from r.RemoteAddr via net.SplitHostPort, falling back to the raw value on split error -- the trusted-proxy-aware client IP function lands in 06-02; do not build it here) so no GORM hooks or query logging capture the raw bearer; load the owning usermodels.User by token.UserID; return &bouncer.Principal{ID: user.ID, MustChangePassword: user.MustChangePassword}, &token, nil. TokenGuard implements bouncer.CredentialGuard only -- it must NOT implement UnauthorizedWriter (D-08: TokenScope, not the guard, owns the 401/403 bodies).

Create middleware/token_scope.go (package middleware, same package as must_change_password.go): InvScope(scope string) pact.Middleware. Body: read bouncer.User(r.Context()); if not ok, write status 401 with body {"error":"Invalid token"} and return (do not call next). Otherwise read bouncer.Credential(r.Context()), type-assert to *models.ApiToken; if the assertion fails or !token.HasScope(scope), write status 403 with body {"error":"Missing required scope: " + scope} and return. Otherwise call next.ServeHTTP(w, r). Write the two JSON bodies with a small unexported writeJSON(w http.ResponseWriter, status int, v any) local to this file (Content-Type: application/json, json.NewEncoder(w).Encode(v)) -- do not reuse bouncer's write401 (different body shape: bool error there, string error here) and do not cross-import controllers.

In fonoteka.go/plugins/golem15/user/plugin.go: in Boot, after the existing classes.JWTSecret(app) and hook-registration code, look up *bouncer.Registry via app.Lookup[*bouncer.Registry](); if absent, construct with bouncer.NewRegistry() and app.Publish(reg); call reg.Register(p.ID(), "jwt", bouncer.NewJWTGuard(secret, classes.GormUsers{App: app})) and return its error. Rewrite Middlewares() to look up the registry (p.app.Lookup[*bouncer.Registry]()), call reg.Middleware("jwt"), and return map[string]pact.Middleware{"jwt.auth": mw} (nil map if either lookup fails, matching the existing nil-on-error convention already used for the JWT-secret failure path).

In fonoteka.go/plugins/golem15/fonoteka/plugin.go: inside the existing if gdb, ok := app.Lookup[*gorm.DB](); ok { ... } block in Boot, after classes.RegisterHooks(gdb) succeeds, look up-or-create the SAME *bouncer.Registry (identical lookup-or-create snippet as the user plugin) and call reg.Register(p.ID(), "inv_token", auth.NewTokenGuard(gdb)), returning its error. Extend Middlewares() to also look up bouncer.Registry and, if reg.Middleware("inv_token") succeeds, add "inv_token": mw to the returned map alongside the existing "inv.must-change-password" entry. Add a new method MiddlewareFactories() map[string]func(param string) pact.Middleware { return map[string]func(string) pact.Middleware{"inv.scope": func(scope string) pact.Middleware { return middleware.InvScope(scope) }} } and declare _ pact.HasMiddlewareFactories = (*Plugin)(nil) alongside the existing interface assertions.
cd /media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go && go vet ./... && go test ./plugins/golem15/fonoteka/... ./plugins/golem15/user/... -short - go test ./plugins/golem15/fonoteka/classes/auth/... -run TestTokenGuard covers every behavior case listed above, using a real (testcontainers) Postgres ApiToken row, not a mock. - go test ./plugins/golem15/fonoteka/middleware/... -run TestInvScope asserts the exact byte bodies {"error":"Invalid token"} and {"error":"Missing required scope: write"} (not the {"error":true,"message":...} shape). - A boot-time test (activating both plugins against a real *backpack.App) asserts reg.Middleware("jwt") and reg.Middleware("inv_token") both resolve without error after party.Activate. - LastUsedAt/LastUsedIP are asserted to change exactly once per AuthenticateCredential call in the test (no double-stamp). Both guards are registered through the same bouncer.Registry; jwt.auth is behaviorally unchanged; inv_token + inv.scope reproduce TokenScope.php's exact 401/403 contract. Task 3 (fonoteka.go, parity): Mount genres under both auth groups, parity seed hook for the token surface, shared-handler + isolation tests fonoteka.go/plugins/golem15/fonoteka/routes.go, fonoteka.go/plugins/golem15/fonoteka/routes_group_test.go, fonoteka.go/parity/genres_seed_test.go, fonoteka.go/parity/manifest.yaml fonoteka.go/plugins/golem15/fonoteka/routes.go (current, full -- 14 lines) fonoteka.go/parity/genres_seed_test.go (full -- seedGenres, upsertParityAlice, upsertParityCollection, mintTestJWT patterns to mirror for a test-only token mint) fonoteka.go/parity/manifest.yaml lines 2803-2841 (the two personal_token genres entries; the sibling GET /_fonoteka/api/v1/genres jwt entry at lines 1514-1532 for the exact status: ported / seed_hook: genres shape to copy) /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/routes.php lines 449-454, 513-514 (the real /api/v1/fonoteka group prefix, middleware, and the genres route's inv.scope:read) In routes.go, add 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)) }) call after the existing JWT group, passing the SAME controllers.ListGenres(p.app) handler value used in the JWT group (proves D-15's shared-handler requirement by construction, not convention). PHP's real group middleware also carries a group-level throttle:fonoteka-api-token (routes.php line 450) -- that bucket does not exist until 06-02, so leave a "// TODO(06-02): throttle:fonoteka-api-token" comment directly above this r.Group call as the exact landing spot; do not add a "throttle:..." string to surf.Use(...) yet (it would fail boot with "unknown middleware" until 06-02 registers the factory).
In parity/genres_seed_test.go, extend seedGenres (reuse the existing Alice + collection setup it already performs) to also mint a test-only personal token: generate 32 random bytes, base64url-encode (no padding) with an "inv_" prefix -- mirroring ApiTokenManager::mint's secret shape but minted directly via GORM (minting stays test-only per D-07, never through a production endpoint); compute sha256 hex of the secret; upsert (idempotent, same Where(...).Take then Create pattern as upsertParityAlice) a fonotekamodels.ApiToken{UserID: alice.ID, Name: <a *string pointing at "Parity MCP">, TokenHash: hash, Scopes: lagoon.Jsonable[[]string]{Data: []string{"read"}, Valid: true}} row; call store.Set("token:mcp-read", secret) so the manifest's existing Authorization: "Bearer {{token:mcp-read}}" fixture headers resolve during replay. Keep this inside the existing seedGenres function and the existing "genres" map entry in seedHooks -- do not introduce a second hook name, since the personal-token genres route needs the identical Alice/collection context seedGenres already builds.

In parity/manifest.yaml, on the GET /api/v1/fonoteka/genres personal_token entry (currently status: pending, no seed_hook key), change status: pending to status: ported and add a seed_hook: genres line directly below status:, matching the exact key placement already used on the GET /_fonoteka/api/v1/genres jwt entry. Do not modify any other manifest entry (in particular leave POST /api/v1/fonoteka/genres personal_token and every other pending entry untouched).

Create routes_group_test.go (matching the existing test package convention in this directory) with httptest-based assertions: (a) a request to /_fonoteka/api/v1/genres with a valid JWT and a request to /api/v1/fonoteka/genres with a valid inv_ token both return 200 with equal GenreList bodies for the same seeded user/collection (shared-handler proof); (b) a request to /api/v1/fonoteka/genres with no Authorization header returns 401 {"error":"Invalid token"}; (c) a request with a well-formed but unknown inv_ bearer returns 401 {"error":"Invalid token"}; (d) a request with a token minted with only ["write"] scopes returns 403 {"error":"Missing required scope: read"} on the same route.
cd /media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go && go vet ./... && go test ./plugins/golem15/fonoteka/... -run TestGenresSharedHandler -short && go test ./parity/... -run TestParityCorpus - The new httptest suite passes all four sub-assertions listed above. - go test ./parity/... -run TestParityCorpus reports GET /api/v1/fonoteka/genres personal_token as passing (not pending) and does not regress the existing GET /_fonoteka/api/v1/genres jwt passing count. - grep -n "status: ported" fonoteka.go/parity/manifest.yaml shows exactly two entries (the pre-existing jwt genres route and the newly-flipped personal_token genres route). Genres is reachable and parity-green under both auth groups through the identical handler; the personal-token guard, scope gate, and 401/403 bodies are proven end to end, not just unit-tested in isolation.

<threat_model>

Trust Boundaries

Boundary Description
client -> Authorization header untrusted bearer credential (JWT or inv_ token) parsed on every request
guard registry -> plugin Boot plugin-declared guard names become live auth middleware; a misregistration is a silent-bypass risk if not fail-loud
inv_token guard -> golem15_fonoteka_api_tokens hash-indexed lookup of an untrusted bearer against stored token_hash

STRIDE Threat Register

Threat ID Category Component Disposition Mitigation Plan
T-06-01 Spoofing bouncer.Registry.Register mitigate Duplicate or unregistered guard names fail Boot loudly (Task 1 test), mirroring surf.RegisterMiddleware's existing fail-boot contract -- no silent no-op auth
T-06-02 Elevation of Privilege inv_token credential vs jwt credential mitigate Each guard is registered and resolved independently; Task 3's shared-handler test proves both groups reach the SAME handler through DIFFERENT guards, and Task 3 asserts the personal-token group rejects a missing/invalid inv_ credential -- full route-table mutual-exclusivity (zero jwt.auth on the token group, zero inv_token/inv.scope on the JWT group) is completed in 06-03 once the route table exists; this plan's partial coverage is the two groups never sharing a middleware list literal
T-06-03 Information Disclosure ApiToken.TokenHash accept Already hidden via json:"-" and Hidden() (Phase 5, verified by 05-06's hidden-marshal test); this plan adds no new serialization path for the hash
T-06-04 Repudiation last_used_at/last_used_ip stamping mitigate TokenGuard stamps via a single UpdateColumns call with no query/error logging of the raw bearer; Task 2 test asserts exactly one stamp per AuthenticateCredential call
T-06-05 Tampering inv_token guard's SHA-256 hash lookup accept Indexed equality lookup (not a byte-for-byte secret compare) is not a timing side-channel per RESEARCH.md's V6 Cryptography note; crypto/subtle is reserved for a future raw-compare path (e.g. OAuth client secrets, Phase 8), not needed here
</threat_model>
cd summercms.go && go vet ./... && go test ./surf/... ./bouncer/... ./pact/... -short cd ../fonoteka.go && go vet ./... && go test ./plugins/golem15/... -short && go test ./parity/... -run TestParityCorpus

<success_criteria>

  • pact.Router and surf.Router/Group support Get/Post/Put/Patch/Delete and colon-parameterized middleware factories.
  • bouncer.Registry resolves both "jwt" and "inv_token" to the same bouncer.User(ctx) accessor; jwt's behavior is unchanged from Phase 3.
  • golem15.fonoteka's inv_token guard and inv.scope middleware reproduce TokenScope.php's exact 401/403 bodies.
  • GET genres is reachable and parity-green on both /_fonoteka/api/v1 (JWT) and /api/v1/fonoteka (personal token) through the identical handler value.
  • go vet ./... and go test ./... are green in both repos (testcontainers-gated tests included). </success_criteria>
Create `.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-01-SUMMARY.md` when done