# Phase 9: Backend admin authentication and schema pipeline - Pattern Map **Mapped:** 2026-09-24 **Files analyzed:** 22 file groups (framework and Fonoteka) **Analogs found:** 21 / 22 All named analogs below were checked as tracked source files with `git ls-files` from their respective repository roots. `../fonoteka.go/...` is tracked in the Fonoteka repository, not a runtime mirror. ## File Classification | New/Modified File | Role | Data Flow | Closest Analog | Match Quality | |---|---|---|---|---| | `bouncer/jwt.go`, `bouncer/mint.go`, tests | service / middleware | request-response | `bouncer/jwt.go`, `bouncer/mint.go` | exact | | `pact/capabilities.go` | capability contract | transform | `pact/capabilities.go` | exact | | new framework admin package: schema types/compiler/registry | service / utility | file-I/O, transform | `phrasebook/loader.go`, `pact/capabilities.go` | role-match | | new framework admin package: auth, permissions, envelope | middleware / utility | request-response | `bouncer/registry.go`, `wire/response.go` | role-match | | new framework admin package: routes and CRUD/relation/settings handlers | controller / route | CRUD, request-response | `plugins/golem15/fonoteka/controllers/genre_controller.go` | role-match | | framework backend-user/role models, provider, migrations | model / migration | CRUD | `plugins/golem15/user/models/user.go`, `plugins/golem15/user/updates/202609220006_create_user_throttle.go` | role-match | | framework `summer admin:create` / reset command | controller / command | request-response | `plugins/golem15/user/console/require_password_change.go` | exact | | `internal/build/artifact.go`, `internal/build/stubs/artifacts.tmpl` | generator / config | file-I/O | same files | exact | | `fonoteka/plugin.go` | provider | event-driven | same file; `plugins/golem15/user/plugin.go` | exact | | `fonoteka/controllers/{albums,artists,collections,genres,styles}.go` | controller | CRUD | `fonoteka/controllers/genre_controller.go` | role-match | | `fonoteka/controllers/{albums,artists,collections,genres,styles}/config_*.yaml` | config | file-I/O, transform | no Go-admin YAML analogue; PHP canonical assets in CONTEXT.md | no analog | | `fonoteka/models/{album,artist,collection,genre,style,settings}/{fields,columns}.yaml` | config | file-I/O, transform | no Go-admin YAML analogue; PHP canonical assets in CONTEXT.md | no analog | | `fonoteka/models/album.go`, `collection.go`, `settings.go` | model / capability | CRUD | same files | exact | | `fonoteka/classes/backend_album_collection_resolver.go` | service | request-response, CRUD | `classes/active_collection.go` | role-match | | `fonoteka/updates/*backend*.go` and `updates/registry.go` | migration / registry | batch | `user/updates/202609220006_create_user_throttle.go`, `user/updates/registry.go` | exact | | framework, bouncer, Fonoteka `*_test.go` | test | request-response, CRUD | package-local Phase 7/8 test files | exact | ## Pattern Assignments ### `bouncer/jwt.go` and `bouncer/mint.go` (JWT audience support) **Analog:** `bouncer/jwt.go`, `bouncer/mint.go` **Guard composition** (`bouncer/jwt.go:68-123`): preserve one guard implementation that extracts the token, verifies claims, resolves its database principal, checks blacklist, then stores that principal in request context. Extend its options/signature for an expected audience; do not build a second parser. ```go sub, iat, _, jti, err := VerifyClaims(raw, g.secret) if err != nil { return nil, err } user, err := g.users.FindByID(r.Context(), uint(id)) if err != nil { return nil, errors.New("Authentication error") } if user == nil { return nil, errors.New(msgUserNotFound) } if g.bl != nil { blocked, err := g.bl.IsBlacklisted(r.Context(), jti) if err != nil { return nil, errors.New("Authentication error") } if blocked { return nil, errors.New(msgBadSignature) } } ``` **Pinned-token validation** (`bouncer/jwt.go:149-169`): retain `HS256` and required expiry, adding `jwt.WithAudience(expected)` at this shared verification boundary. Empty secrets remain a boot/request failure, never a fallback. ```go if strings.TrimSpace(secret) == "" { return "", time.Time{}, time.Time{}, "", fmt.Errorf("bouncer: jwt secret is empty") } parser := jwt.NewParser(jwt.WithValidMethods([]string{"HS256"}), jwt.WithExpirationRequired()) claims := jwt.MapClaims{} _, err = parser.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (any, error) { return []byte(secret), nil }) ``` **Mint claims** (`bouncer/mint.go:22-48`): add `Audience` to `jwt.RegisteredClaims`, preserving jti generation, issuer/sub/expiry handling, and HS256 signing. ### `pact/capabilities.go` (capability contracts and controller hooks) **Analog:** `pact/capabilities.go:100-112,136-148` Add small optional interfaces beside `AdminController` (`HasPermissions`, `HasNavigation`, `HasSettings`, controller hook interfaces); consumers type-assert them. Do not put GORM/admin-package imports into the mandatory plugin interface if a narrow optional interface avoids it. ```go type AdminController interface { ID() string ModelName() string ConfigDir() string } type HasAdminControllers interface { AdminControllers() []AdminController } ``` ### New framework admin schema compiler and immutable registry **Analog:** `phrasebook/loader.go` (embedded-FS loading) and `pact/capabilities.go:100-112` (capability discovery) No existing typed admin-schema compiler exists. Follow the existing fail-at-activation style: collect plugin capabilities once, parse only embedded `fs.FS` assets, validate references/options/field kinds immediately, and retain typed source schemas. Translate only when serializing a request result; `phrasebook.Translator.GetIn` already has raw-key fallback (`phrasebook/translator.go:125-139`). ```go func (t *Translator) GetIn(locale, key string, params map[string]string) string { if t == nil { return key } e, _, ok := t.find(locale, key) if !ok { return key } return interpolate(e.text, params) } ``` Use `yaml.NewDecoder(..., yaml.DisallowUnknownField())` per the Phase research; this is intentionally a new pattern, with no tracked in-tree decoder analogue. ### New framework admin authentication, permission middleware, and response envelope **Analog:** `bouncer/registry.go:57-91`, `bouncer/context.go`, and `wire/response.go` Build one registered `backend` guard and derive its middleware through the registry. The admin API wrapper must map failures into the locked admin envelope, rather than reuse Fonoteka’s PHP-parity response shape. ```go principal, cred, err := authenticate(ng.g, r) if err != nil || principal == nil { if wtr, ok := ng.g.(UnauthorizedWriter); ok { if err == nil { err = errors.New("unauthenticated") } wtr.WriteUnauthorized(w, err) return } next.ServeHTTP(w, r) return } ctx := WithUser(r.Context(), principal) next.ServeHTTP(w, r.WithContext(ctx)) ``` Authorization order is fixed: backend guard -> known controller registry entry -> `RequiredPermissions()` / wildcard role evaluation -> handler. Navigation filtering is additional UX, never the authorization check. ### New framework admin routes and handlers (login, schemas, CRUD, relations, settings) **Analog:** `../fonoteka.go/plugins/golem15/fonoteka/routes.go:10-77`, `controllers/genre_controller.go:53-91` Mount the admin API with `GroupRaw`, apply only `backend` (and optionally locale) middleware, constrain parameters immediately after their route, and resolve all controller IDs from the boot-built registry. Never derive package paths, SQL identifiers, model types, or relation names from raw path input. ```go r.GroupRaw("/_admin/api/v1", surf.Use("backend"), func(g pact.Router) { g.Get("/...", handler) g.Delete("/.../{id}", handler) g.Where("id", "[0-9]+") }) ``` For writes, copy the established safe pipeline rather than binding JSON directly: read map, intersect compiled form-field and model `Fillable` allowlists, validate, run the hook, fill, then save. For bulk delete, use a transaction and fetch/delete one scoped record at a time so GORM callbacks execute. ```go errs, err := lagoon.Validate(r.Context(), gdb, models.User{}, rules, fields, appTranslator(app)) if err != nil { writeOpaque500(w); return } if len(errs) > 0 { writeValidation(w, errs); return } ``` Relation writes must implement Lagoon’s explicit pivot contract (`lagoon/relations.go:9-27`): plugin supplies the join model and the framework does direct transactional inserts/deletes after `RelationBeforeLink`, never association `Append`/`Replace`. ### Framework backend models, provider, migrations and console commands **Analogs:** `../fonoteka.go/plugins/golem15/user/models/user.go`; `../fonoteka.go/plugins/golem15/user/updates/202609220006_create_user_throttle.go:8-34`; `../fonoteka.go/plugins/golem15/user/console/require_password_change.go:16-55` Use explicit `gorm:"column:..."` model fields and table names, `gorm.DeletedAt` for backend users, and a `bouncer.UserProvider` that returns only valid/activated backend principals. Migrations are named gormigrate entries registered from `init`; commands resolve `*gorm.DB` from `backpack.App`, trim/validate input, return contextual errors, and only report success after one confirmed update. ```go return bonfire.Command{ Name: "user:require-password-change", Args: []bonfire.Arg{{Name: "email", Required: true}}, Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error { if app == nil { return fmt.Errorf("user:require-password-change: app is nil") } gdb, ok := app.Lookup[*gorm.DB](); if !ok || gdb == nil { return fmt.Errorf("...database is not configured") } // validate, load, mutate, check RowsAffected, then out.Success return nil }, } ``` ### `internal/build/artifact.go` and `internal/build/stubs/artifacts.tmpl` **Analog:** same files, especially `internal/build/artifact.go:230-303` and `internal/build/stubs/artifacts.tmpl:92-119` Keep the generator’s current validate -> duplicate-check every target -> render -> rollback-created-files-on-error -> `finishArtifact` sequence. Change generated asset locations to Winter shape: controller `config_form.yaml`/`config_list.yaml`, model `fields.yaml`/`columns.yaml`, with optional filter/relation configs; keep `ConfigDir()` rooted at `controllers/`. ### Fonoteka plugin registration, controller declarations, YAML assets, and hooks **Analogs:** `../fonoteka.go/plugins/golem15/fonoteka/plugin.go:32-64,210-249`; `../fonoteka.go/plugins/golem15/fonoteka/controllers/genre_controller.go:1-19`; `../fonoteka.go/plugins/golem15/fonoteka/classes/active_collection.go:30-113` Extend the existing embedded-FS plugin rather than creating an app-specific API router. Add compile-time capability assertions and return deterministic slices/maps for permissions, navigation, settings and five `AdminControllers`. Controller structs should remain thin declarations/hook owners; framework handlers own generic HTTP CRUD. The Albums resolver/hook belongs in Fonoteka because it names frontend users and collections. Copy the resolver’s defensive DB transaction/query style: check nil GORM, scope every query, turn `gorm.ErrRecordNotFound` into the explicitly required outcome, and propagate other database errors. Do not encode Fonoteka collection or pivot columns in the framework. ### Fonoteka models and relation/settings capabilities **Analogs:** `models/collection.go:11-50`; `models/settings.go:5-24`; `models/album.go:85-123` Keep `Fillable` and `Rules` on each concrete model as the mass-assignment/validation backstop. The settings singleton uses the already-typed `Settings` model; schema-driven GET/PUT must still call `lagoon.Fill`/`lagoon.Validate`. For the editors relation, retain the declared many-to-many metadata and explicit join model rather than association-mode writes. ```go func (Settings) Fillable() []string { return []string{"search_use_typesense"} } func (Settings) Rules() map[string]string { return map[string]string{"search_use_typesense": "boolean"} } func (c *Collection) BeforeDelete(tx *gorm.DB) error { return lagoon.WithSoftDeleteCascade(tx, func(tx *gorm.DB) error { return tx.Where("collection_id = ?", c.ID).Delete(&Album{}).Error }) } ``` ## Shared Patterns ### Guard registration and request principal **Source:** `../fonoteka.go/plugins/golem15/user/plugin.go:64-97`, `bouncer/registry.go:63-91` Reuse the lookup-or-publish registry idiom and register a named guard at plugin boot. Backend JWTs must be bearer-only (no user-cookie reuse), use `admin.jwt.secret`, an audience, backend-user provider, and the existing blacklist store. ### Raw API group **Source:** `../fonoteka.go/plugins/golem15/fonoteka/routes.go:67-76`; `surf/router.go:149-169` The admin group is `GroupRaw`: it cannot inherit Fonoteka house middleware or its response format. It receives the new framework envelope on every success/error path. ### Validation, assignment, lifecycle, and soft delete **Source:** `lagoon/fill.go:27-58`; `lagoon/validate.go:22-40`; `models/collection.go:46-50` Schema visibility is not a write permit. Submit only map fields through the combined allowlist, `Validate`, `Fill`, hooks and `Save`. Bulk delete must call GORM `Delete` per loaded scoped row so the collection cascade continues to run. ### Localization **Source:** `phrasebook/translator.go:119-139` Choose locale from the request/admin principal, then call `GetIn` while serializing labels/comments/tabs/options/navigation. Cache parsed keys and typed schema only, never a localized JSON response. ## No Analog Found | File / concern | Role | Data Flow | Reason | |---|---|---|---| | typed Winter YAML form/list/filter/relation compiler | service | file-I/O, transform | No admin YAML parser exists in either Go repository. Follow strict-decoder design from `09-RESEARCH.md` and preserve existing boot-fail conventions. | | admin-specific uniform error envelope | utility | request-response | Existing API envelopes intentionally preserve PHP parity and conflict with D-10. Implement the locked new envelope only in the new framework admin package. | | Fonoteka admin YAML assets | config | file-I/O | Assets are a first port; source-of-truth paths are listed in `09-CONTEXT.md` canonical references. | ## Metadata **Analog search scope:** `bouncer`, `pact`, `lagoon`, `phrasebook`, `surf`, `party`, `internal/build`, and tracked Fonoteka user/Fonoteka plugins. **Files scanned:** 29 source/config/test analogs. **Pattern extraction date:** 2026-09-24