docs(09): create phase plan

This commit is contained in:
Jakub Zych
2026-09-24 16:45:45 +02:00
parent 1fbf492450
commit 01d3871510
16 changed files with 2433 additions and 31 deletions

View File

@@ -0,0 +1,236 @@
# 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