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

237 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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/<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.
```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