Files
summercms/.planning/phases/09-backend-admin-authentication-and-schema-pipeline/09-PATTERNS.md
2026-09-24 16:45:45 +02:00

14 KiB
Raw Blame History

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