14 KiB
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.
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.
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.
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).
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.
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.
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.
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.
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/<name>.
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.
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