docs(07): add pattern map
This commit is contained in:
@@ -0,0 +1,488 @@
|
||||
# Phase 7: User plugin and authentication - Pattern Map
|
||||
|
||||
**Mapped:** 2026-09-22
|
||||
**Files analyzed:** 34 (new/modified, both repos)
|
||||
**Analogs found:** 30 / 34 (4 net-new primitives with no direct in-repo precedent, documented under "No Analog Found")
|
||||
|
||||
## File Classification
|
||||
|
||||
| New/Modified File | Repo | Role | Data Flow | Closest Analog | Match Quality |
|
||||
|---|---|---|---|---|---|
|
||||
| `plugins/golem15/user/routes.go` (new) | fonoteka.go | route | request-response | `plugins/golem15/fonoteka/routes.go` | exact |
|
||||
| `plugins/golem15/user/controllers/api_controller.go` (new) | fonoteka.go | controller | request-response | `plugins/golem15/fonoteka/controllers/genre_controller.go` | role-match (list vs. auth actions, but identical DTO/JSON/error idiom) |
|
||||
| `plugins/golem15/user/controllers/api_controller.go` (avatar handlers) | fonoteka.go | controller | file-I/O | `lagoon/attach/file.go` + `thumb.go` (no HTTP handler analog exists yet) | partial |
|
||||
| `plugins/golem15/user/plugin.go` (extend) | fonoteka.go | provider/plugin | event-driven + request-response | existing `plugins/golem15/user/plugin.go` (self) + `examples/hello/plugins/base/plugin.go` (mail/lang capability wiring) | exact (self) / role-match (mail/lang) |
|
||||
| `plugins/golem15/user/classes/user_lookup.go` (extend) | fonoteka.go | service | CRUD | existing `plugins/golem15/user/classes/user_lookup.go` (self) | exact |
|
||||
| `plugins/golem15/user/classes/throttle.go` (new) | fonoteka.go | service | CRUD | `plugins/golem15/fonoteka/classes/auth/token_guard.go` (DB-backed credential/attempt service shape) | role-match |
|
||||
| `plugins/golem15/user/classes/events.go` (new) | fonoteka.go | service (event) | event-driven | `examples/hello/plugins/greeter/plugin.go` (`HelloEvent`) | exact |
|
||||
| `plugins/golem15/user/classes/codes.go` (new) | fonoteka.go | service | CRUD | `plugins/golem15/fonoteka/classes/auth/token_guard.go` (hash-compare pattern) | partial |
|
||||
| `plugins/golem15/user/models/user.go` (extend far beyond stub) | fonoteka.go | model | CRUD | existing `plugins/golem15/user/models/user.go` (self) + `plugins/golem15/fonoteka/models/api_token.go` (Fillable/Hidden shape) | exact (self) |
|
||||
| `plugins/golem15/user/models/throttle.go` (new) | fonoteka.go | model | CRUD | `plugins/golem15/fonoteka/models/api_token.go` | role-match |
|
||||
| `plugins/golem15/user/updates/2026*_extend_users.go` (new, ALTER-only) | fonoteka.go | migration | batch | `plugins/golem15/fonoteka/updates/10_album_slice.go` | exact |
|
||||
| `plugins/golem15/user/updates/2026*_create_user_throttle.go` (new, CREATE) | fonoteka.go | migration | batch | `plugins/golem15/user/updates/00_base.go` | exact |
|
||||
| `plugins/golem15/user/updates/2026*_create_jwt_blacklist.go` (new, CREATE) | fonoteka.go | migration | batch | `plugins/golem15/user/updates/00_base.go` | exact |
|
||||
| `plugins/golem15/user/config/config.yaml` (extend) | fonoteka.go | config | — | existing `plugins/golem15/user/config/config.yaml` (self) | exact |
|
||||
| `plugins/golem15/user/lang/{en,pl}/lang.yaml` (new) | fonoteka.go | config (i18n) | — | `examples/hello/plugins/base/lang/{en,pl}/lang.yaml` | exact |
|
||||
| `plugins/golem15/user/views/mail/{activate,restore,reactivate}(-en).htm` + `layouts/*.htm` (new) | fonoteka.go | config (mail template) | — | `examples/hello/plugins/base/views/mail/{hello,hello-en}.htm`, `views/mail/layouts/hello.htm` | exact |
|
||||
| `plugins/golem15/user/console/require_password_change.go` (new) | fonoteka.go | console command | request-response (CLI) | `examples/hello/plugins/greeter/plugin.go` `Commands()` (no-arg) + `lagoon/commands.go` `migrate:rollback` (flag/arg-taking) | role-match |
|
||||
| `plugins/golem15/fonoteka/plugin.go` (extend: `getApiArray` listener, mail-mailer wiring) | fonoteka.go | provider/plugin | event-driven | existing `plugins/golem15/fonoteka/plugin.go` (self) | exact |
|
||||
| `plugins/golem15/fonoteka/routes.go` (extend: real `me/locale`, `tokens` groups) | fonoteka.go | route | request-response | existing `plugins/golem15/fonoteka/routes.go` (self, the empty `jwt_locale`/token groups) | exact |
|
||||
| `plugins/golem15/fonoteka/controllers/api/token_api_controller.go` (new) | fonoteka.go | controller | CRUD | `plugins/golem15/fonoteka/controllers/genre_controller.go` | role-match |
|
||||
| `plugins/golem15/fonoteka/controllers/api/me_locale_controller.go` (new) | fonoteka.go | controller | request-response | `plugins/golem15/fonoteka/controllers/genre_controller.go` | role-match |
|
||||
| `plugins/golem15/fonoteka/classes/auth/api_token_manager.go` (new) | fonoteka.go | service | CRUD | `plugins/golem15/fonoteka/classes/auth/token_guard.go` (sibling in same package, same hash convention) | exact |
|
||||
| `plugins/golem15/fonoteka/middleware/must_change_password.go` (unchanged, reused) | fonoteka.go | middleware | request-response | existing file itself | exact |
|
||||
| `plugins/golem15/fonoteka/middleware/token_scope.go` (unchanged, reused) | fonoteka.go | middleware | request-response | existing file itself | exact |
|
||||
| `bouncer/mint.go` (new) | summercms.go | service | request-response | `bouncer/jwt.go` (`Verify`, parser construction) | exact |
|
||||
| `bouncer/refresh.go` (new) | summercms.go | service | request-response | `bouncer/jwt.go` (`Verify`, error mapping) | exact |
|
||||
| `bouncer/blacklist.go` (new: `Store` interface + Postgres impl) | summercms.go | model+service (Store) | CRUD | `surf/limiter_store.go` (`Store` interface + `MemoryStore`) | exact (interface shape) |
|
||||
| `bouncer/password.go` (new) | summercms.go | service | transform | `bouncer/jwt.go` (small stateless helper package convention) | partial |
|
||||
| `bouncer/context.go` (extend: `Principal.PreferredLocale`) | summercms.go | model | — | existing `bouncer/context.go` (self) | exact |
|
||||
| `surf/locale_from_principal.go` (new middleware) | summercms.go | middleware | request-response | `plugins/golem15/fonoteka/middleware/must_change_password.go` (bouncer.User + ctx rewrite shape) + `surf/router.go:650` `locale()` | role-match |
|
||||
| `lagoon/validate.go` (extend: `email`, `confirmed`, `different`, `mimes` tokens) | summercms.go | utility | transform | existing `lagoon/validate.go` `validateField` switch (self) | exact |
|
||||
| `fonoteka.go/parity/manifest.yaml` (extend) | fonoteka.go | test (fixture manifest) | batch | existing manifest's `me/locale`/`tokens` pending entries + genre route entries | exact |
|
||||
| `fonoteka.go/parity/capture-rules.yaml` (extend) | fonoteka.go | test (capture policy) | batch | existing `capture-rules.yaml` (self, `login` capture rule already present) | exact |
|
||||
| `fonoteka.go/parity/fixtures/routes/*_user-api-v1-*.yaml` + `nuxt/nuxt-auth.yaml` (new) | fonoteka.go | test (fixture) | batch | `parity/fixtures/routes/POST___fonoteka_api_v1_genres_jwt.yaml` + `parity/fixtures/nuxt/nuxt-browse.yaml` | exact |
|
||||
|
||||
## Pattern Assignments
|
||||
|
||||
### `plugins/golem15/user/routes.go` (route, request-response)
|
||||
**Analog:** `fonoteka.go/plugins/golem15/fonoteka/routes.go` (lines 1-35, read in full)
|
||||
|
||||
**Imports pattern:**
|
||||
```go
|
||||
import (
|
||||
"git.golem15.com/golem15/fonoteka/plugins/golem15/user/controllers"
|
||||
"git.golem15.com/golem15/summercms/pact"
|
||||
"git.golem15.com/golem15/summercms/surf"
|
||||
)
|
||||
```
|
||||
|
||||
**Core group-builder pattern** (mirrors the existing fonoteka `Routes` method, including the "empty group builder = pending in routes.php" convention used for anything NOT ported this phase):
|
||||
```go
|
||||
func (p *Plugin) Routes(r pact.Router) error {
|
||||
r.Group("/_user/api/v1", surf.Use("throttle:user-api"), func(g pact.Router) {
|
||||
g.Post("/login", controllers.Login(p.app))
|
||||
g.Post("/logout", controllers.Logout(p.app))
|
||||
g.Get("/fetch", controllers.Fetch(p.app))
|
||||
g.Post("/refresh", controllers.Refresh(p.app))
|
||||
g.Post("/register", controllers.Register(p.app))
|
||||
g.Post("/forgot-password", controllers.ForgotPassword(p.app))
|
||||
g.Post("/reset-password", controllers.ResetPassword(p.app))
|
||||
g.Post("/activate", controllers.Activate(p.app))
|
||||
g.Post("/activate-by-code", controllers.ActivateByCode(p.app))
|
||||
g.Post("/update", controllers.Update(p.app))
|
||||
g.Post("/change-password", controllers.ChangePassword(p.app))
|
||||
g.Post("/avatar", controllers.UploadAvatar(p.app))
|
||||
g.Post("/avatar/remove", controllers.RemoveAvatar(p.app))
|
||||
g.Post("/marketing-consent", controllers.MarketingConsent(p.app))
|
||||
g.Get("/oauth-providers", controllers.OAuthProviders(p.app))
|
||||
})
|
||||
return nil
|
||||
}
|
||||
```
|
||||
Note: per RESEARCH.md's Anti-Pattern, do NOT add a hand-written `OPTIONS` catch-all — `surf/cors.go`'s existing CORS middleware already 204s every `OPTIONS` request. Do not add `jwt.auth` on this group (D-01: auth is per-handler via `bouncer.Middleware`/guard calls inside each handler body, exactly as PHP's `ApiController::authorize()` is per-action, not per-group).
|
||||
|
||||
Auth-per-handler note: unlike `fonoteka`'s groups (which attach `jwt.auth` at the group level), `fetch`/`update`/`change-password`/`avatar*`/`marketing-consent`/`logout` must call the guard explicitly inside the handler (see `bouncer.jwtGuard.Authenticate` in `bouncer/jwt.go:84-108` for the exact call shape to replicate without the `Middleware` wrapper).
|
||||
|
||||
---
|
||||
|
||||
### `plugins/golem15/user/controllers/api_controller.go` (controller, request-response)
|
||||
**Analog:** `fonoteka.go/plugins/golem15/fonoteka/controllers/genre_controller.go` (full file, 155 lines)
|
||||
|
||||
**Imports pattern** (lines 1-19):
|
||||
```go
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.golem15.com/golem15/fonoteka/plugins/golem15/fonoteka/classes"
|
||||
"git.golem15.com/golem15/fonoteka/plugins/golem15/fonoteka/models"
|
||||
"git.golem15.com/golem15/summercms/backpack"
|
||||
"git.golem15.com/golem15/summercms/bouncer"
|
||||
"git.golem15.com/golem15/summercms/lagoon"
|
||||
"git.golem15.com/golem15/summercms/wire"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
```
|
||||
|
||||
**Core handler-factory pattern** (lines 53-92): every handler is `func Xxx(app *backpack.App) http.HandlerFunc` closing over `app`; inside, resolve `*gorm.DB` via `app.Lookup[*gorm.DB]()`, resolve the caller via `bouncer.User(r.Context())` (for handlers behind a guard call) or skip it (for `login`/`register`/`forgot-password`), then do the work and call `writeJSON`/`writeOpaque500`.
|
||||
|
||||
**Error/response conventions** (lines 88-91, 148-154):
|
||||
```go
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-cache, private")
|
||||
writeJSON(w, http.StatusOK, GenreList{Data: rows})
|
||||
...
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) { wire.WriteJSON(w, status, v) }
|
||||
func writeOpaque500(w http.ResponseWriter) { wire.WriteOpaque500(w) }
|
||||
```
|
||||
D-13 requires per-endpoint distinct error envelopes (`{error: string}`, `{error:true,message}`, `{error,errors}`) — do NOT centralize error rendering the way `writeOpaque500` does for the generic 500 case; each handler builds its own envelope literal, matching PHP's `ApiController` method bodies exactly (see RESEARCH.md D-13 and the `iss`/`prv`/refresh algorithm in RESEARCH.md's Code Examples section, lines 394-483, for the login/register/refresh bodies specifically).
|
||||
|
||||
**Validation pattern to reuse** (from `parseNonEmpty`, lines 94-110): parse-and-422 idiom — validate first, write `422 {"error":"Validation failed","errors":{...}}` and return before touching the DB. Apply the same shape for register/update/change-password once `lagoon.Validate`'s new tokens (`email`, `confirmed`, `different`) land.
|
||||
|
||||
**Avatar upload/remove sub-pattern (file-I/O):**
|
||||
**Analog:** `summercms.go/lagoon/attach/file.go` (`File` model, `DeleteForOwner`, `blobKeysFor`) + `thumb.go` (`Thumb` method, line 107) — no existing HTTP handler wires these yet (D-04 explicitly notes this is the port's first multipart endpoint), so the controller-side wiring is net-new; only the storage primitives have a precedent. Apply the group's `body.limit:N` middleware (see `surf/bodylimit.go` pattern below) before `r.ParseMultipartForm`, and persist via `attach.File{AttachmentType: owner.MorphName(), AttachmentID: ..., Field: "avatar", ...}` then call `f.Thumb(ctx, bucket, 128, 128, "auto")` for `avatar_url`, matching PHP's `getAvatarThumb()`.
|
||||
|
||||
---
|
||||
|
||||
### `plugins/golem15/user/plugin.go` (extend)
|
||||
**Analog (self):** existing `fonoteka.go/plugins/golem15/user/plugin.go` (full file, 88 lines) — keep the existing `Register`/`Boot`/`ConfigFS`/`Migrations`/`Models`/`Middlewares` shape; extend `Boot` to also: publish the blacklist Store, register the `user-api` bucket via `surf.BucketProvider` (see fonoteka's `Buckets()` at `plugins/golem15/fonoteka/plugin.go:102-149` for the exact `map[string]surf.Bucket` shape and `surf.TrustedProxies` usage), and register the `golem15.user.getApiArray` event type on `app.Events` if not already present.
|
||||
|
||||
**Mail/lang capability wiring analog:** `summercms.go/examples/hello/plugins/base/plugin.go` (full file, 55 lines):
|
||||
```go
|
||||
var (
|
||||
_ pact.HasConfig = (*Plugin)(nil)
|
||||
_ pact.HasLang = (*Plugin)(nil)
|
||||
_ pact.HasMailTemplates = (*Plugin)(nil)
|
||||
)
|
||||
|
||||
//go:embed lang
|
||||
var langFS embed.FS
|
||||
|
||||
//go:embed views/mail
|
||||
var mailFS embed.FS
|
||||
|
||||
func (p *Plugin) LangFS() fs.FS { return langFS }
|
||||
|
||||
func (p *Plugin) MailTemplatesFS() fs.FS { return mailFS }
|
||||
func (p *Plugin) MailTemplates() []string {
|
||||
return []string{
|
||||
"golem15.user::mail.activate", "golem15.user::mail.activate-en",
|
||||
"golem15.user::mail.restore", "golem15.user::mail.restore-en",
|
||||
"golem15.user::mail.reactivate", "golem15.user::mail.reactivate-en",
|
||||
}
|
||||
}
|
||||
func (p *Plugin) MailLayouts() map[string]string {
|
||||
return map[string]string{"user": "golem15.user::mail.layouts.user"}
|
||||
}
|
||||
```
|
||||
(Layout/template naming is illustrative — confirm the exact dotted names the `postcard.Catalog.Register` call expects by reading `postcard/templates.go` at plan time; the shape above is the confirmed live idiom.)
|
||||
|
||||
---
|
||||
|
||||
### `plugins/golem15/user/classes/user_lookup.go` (extend)
|
||||
**Analog (self):** existing file (full file, 52 lines). Extend `FindByID` to populate `bouncer.Principal.PreferredLocale` from the new `users.preferred_locale` column:
|
||||
```go
|
||||
return &bouncer.Principal{
|
||||
ID: row.ID,
|
||||
MustChangePassword: row.MustChangePassword,
|
||||
PreferredLocale: row.PreferredLocale, // NEW field, see bouncer/context.go
|
||||
}, nil
|
||||
```
|
||||
`JWTSecret` (lines 41-51) is the exact "fail boot on empty config" idiom to copy for any other new required-secret config key this phase introduces (e.g., if a separate secret is ever needed — none currently planned).
|
||||
|
||||
---
|
||||
|
||||
### `plugins/golem15/user/classes/throttle.go` (service, CRUD)
|
||||
**Analog:** `fonoteka.go/plugins/golem15/fonoteka/classes/auth/token_guard.go` (full file, 89 lines) — a DB-backed lookup-then-update-columns service with its own small package-level helpers (`bearerToken`, `remoteIP`). Reuse the `remoteIP(r *http.Request) string` helper verbatim (lines 79-88) for throttle's per-IP keying. The `UpdateColumns` idiom (lines 52-57) is the pattern for `Throttle`'s attempt-count increments — see RESEARCH.md's `CheckAndRecordLogin` Code Example (lines 452-483) for the full algorithm to port, keyed exactly as documented in Pitfall 5 (`WHERE user_id = ? AND (ip_address = ? OR ip_address IS NULL)`).
|
||||
|
||||
---
|
||||
|
||||
### `plugins/golem15/user/classes/events.go` (service/event, event-driven)
|
||||
**Analog:** `summercms.go/examples/hello/plugins/greeter/plugin.go` `HelloEvent` (lines 121-137) + `festival/bus.go` `Collectable`/`Collect` (full file, 150 lines).
|
||||
```go
|
||||
type GetApiArrayEvent struct {
|
||||
User *models.User
|
||||
data map[string]any
|
||||
}
|
||||
func (e *GetApiArrayEvent) Collected() map[string]any {
|
||||
if e.data == nil {
|
||||
e.data = map[string]any{}
|
||||
}
|
||||
return e.data
|
||||
}
|
||||
var _ festival.Collectable = (*GetApiArrayEvent)(nil)
|
||||
```
|
||||
Registration/consumption idiom (greeter `plugin.go` lines 43-56, using `app.Events.Listen[*T]`):
|
||||
```go
|
||||
if app != nil && app.Events != nil {
|
||||
app.Events.Listen[*GetApiArrayEvent]("golem15.user", func(ctx context.Context, e *GetApiArrayEvent) error {
|
||||
// golem15.user's own base-payload listener, if the payload build
|
||||
// itself is expressed as a listener rather than inline in the handler
|
||||
return nil
|
||||
})
|
||||
}
|
||||
```
|
||||
Firing idiom (from the same `Boot`/handler call site, mirrored on `p.app.Events.Collect(ctx, &GetApiArrayEvent{...})` per `examples/hello/plugins/greeter/plugin.go:98`).
|
||||
|
||||
---
|
||||
|
||||
### `plugins/golem15/user/classes/codes.go` (service, CRUD)
|
||||
**Analog:** `fonoteka.go/plugins/golem15/fonoteka/classes/auth/token_guard.go` — the sha256-hex hashing convention (lines 43-44) sets the precedent for any new hash-at-rest logic; for the constant-time reset/activation code comparison itself, there is no existing analog (see "No Analog Found" — `crypto/subtle.ConstantTimeCompare` is a stdlib-only addition per RESEARCH.md D-15).
|
||||
|
||||
---
|
||||
|
||||
### `plugins/golem15/user/models/user.go` (extend far beyond stub)
|
||||
**Analog (self):** existing file (full file, 19 lines) for the `TableName()`/`Hidden()`/`init(){ Register(User{}) }` skeleton to keep.
|
||||
**Fillable/Hidden shape analog:** `fonoteka.go/plugins/golem15/fonoteka/models/api_token.go` (full file, 51 lines) — shows the `Fillable() []string` + `Hidden() []string` convention (lines 29-36) and the `lagoon.Jsonable[T]` field type (line 16-17) for any JSON-array column the extended `User` needs (e.g., `permissions` if it ever needs true storage — currently stubbed empty per RESEARCH.md Anti-Patterns).
|
||||
|
||||
---
|
||||
|
||||
### `plugins/golem15/user/models/throttle.go` / `updates/*` (model + migration)
|
||||
**Analog:** `fonoteka.go/plugins/golem15/fonoteka/models/api_token.go` for the model shape; `fonoteka.go/plugins/golem15/user/updates/00_base.go` (full file, 34 lines) for a **CREATE TABLE** migration:
|
||||
```go
|
||||
var migrations = []*gormigrate.Migration{
|
||||
{
|
||||
ID: "202609170001_create_users",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
return tx.Exec(`CREATE TABLE users (...)`).Error
|
||||
},
|
||||
Rollback: func(tx *gorm.DB) error {
|
||||
return tx.Exec(`DROP TABLE IF EXISTS users`).Error
|
||||
},
|
||||
},
|
||||
}
|
||||
func init() { Register(migrations...) }
|
||||
```
|
||||
For the **appended ALTER** migrations on `users` (new columns: name/surname/username/avatar-related/activation+reset codes+issued-at/has_self_set_password/organisation_id/organisation_role/preferred_locale/marketing_consent/tokens-valid-after), the analog is `fonoteka.go/plugins/golem15/fonoteka/updates/10_album_slice.go` (lines 27-50, read directly):
|
||||
```go
|
||||
var albumSliceMigrations = []*gormigrate.Migration{
|
||||
{
|
||||
ID: "202609180003_widen_collections",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
return execStmts(tx, []string{
|
||||
`ALTER TABLE golem15_fonoteka_collections ADD COLUMN description TEXT`,
|
||||
`ALTER TABLE golem15_fonoteka_collections ADD COLUMN public_token TEXT`,
|
||||
`ALTER TABLE golem15_fonoteka_collections ADD CONSTRAINT ... UNIQUE (public_token)`,
|
||||
})
|
||||
},
|
||||
Rollback: func(tx *gorm.DB) error {
|
||||
return execStmts(tx, []string{ /* reverse order, DROP COLUMN */ })
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
Reuse the `execStmts(tx, []string{...})` helper convention (defined alongside `10_album_slice.go` in the same package) for multi-statement migrations, and the `00_base.go`/`10_organisations.go` file comment convention of citing the exact PHP `updates/vX.Y.Z/*.php` file each migration folds (see `10_organisations.go` lines 8-13 for the citation style). **Never edit `00_base.go` or `10_organisations.go` themselves** — this phase's changes are new, separately-ID'd migration files only (P5 D-03).
|
||||
|
||||
---
|
||||
|
||||
### `plugins/golem15/user/console/require_password_change.go` (console command, request-response/CLI)
|
||||
**Analog:** `summercms.go/examples/hello/plugins/greeter/plugin.go` `Commands()` (lines 78-119) for the `bonfire.Command{Name, Description, Run}` shape, and `summercms.go/lagoon/commands.go` `migrate:rollback` (lines 31-50) for a command that takes a parameter:
|
||||
```go
|
||||
{
|
||||
Name: "migrate:rollback",
|
||||
Description: "Roll back the last migration of a plugin",
|
||||
Flags: []bonfire.Flag{{
|
||||
Name: "plugin",
|
||||
Description: "Plugin ID whose last migration to roll back",
|
||||
}},
|
||||
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
||||
plugin, _ := in.Flag("plugin")
|
||||
...
|
||||
out.Success(fmt.Sprintf("rolled back last migration of %s", plugin))
|
||||
return nil
|
||||
},
|
||||
},
|
||||
```
|
||||
For a **positional** argument (`user:require-password-change <email>`), use `bonfire.Command.Args []bonfire.Arg{{Name: "email", Required: true}}` (shape confirmed in `bonfire/command.go` lines 34-38, 106-119) and read it via `in.Argument("email")` (interface at `bonfire/command.go` lines 41-45). Wire the command into the plugin via `pact.HasCommands`/`Commands() []bonfire.Command` exactly as the `internal/build/stubs/plugin.tmpl` scaffold declares it (line 52: `func (p *Plugin) Commands() []bonfire.Command { return generatedCommands() }` — the user plugin's own `Commands()` method is hand-written, not generated, since it is not scaffolded by `make:*`).
|
||||
|
||||
---
|
||||
|
||||
### `plugins/golem15/user/lang/{en,pl}/lang.yaml`
|
||||
**Analog:** `summercms.go/examples/hello/plugins/base/lang/{en,pl}/lang.yaml` — read the two files at plan time for the exact YAML key-nesting convention (dotted `HasLang`/`phrasebook` lookup keys) before authoring register/login/validation message keys.
|
||||
|
||||
### `plugins/golem15/user/views/mail/*.htm`
|
||||
**Analog:** `summercms.go/examples/hello/plugins/base/views/mail/{hello.htm, hello-en.htm}` and `views/mail/layouts/hello.htm` — copy the `-en` sibling-file convention (C-05, P4 D-08/20: caller picks the suffix, the catalog does no locale selection itself per `postcard/mailer.go:52-53`) and the layout-alias registration (`MailLayouts()` map key → `golem15.user::mail.layouts.user`).
|
||||
|
||||
---
|
||||
|
||||
### `plugins/golem15/fonoteka/plugin.go` (extend)
|
||||
**Analog (self):** existing file (full file, 154 lines). Add the `getApiArray` listener registration inside `Boot` (after the existing `inv_token` guard registration, lines 60-63), following the exact `app.Events.Listen[*T]` idiom from `examples/hello/plugins/greeter/plugin.go:43-56`:
|
||||
```go
|
||||
if app.Events != nil {
|
||||
app.Events.Listen[*userevents.GetApiArrayEvent]("golem15.fonoteka", func(ctx context.Context, e *userevents.GetApiArrayEvent) error {
|
||||
m := e.Collected()
|
||||
m["organisation_id"] = e.User.OrganisationID
|
||||
m["organisation_role"] = e.User.OrganisationRole
|
||||
m["must_change_password"] = e.User.MustChangePassword
|
||||
m["preferred_locale"] = e.User.PreferredLocale
|
||||
return nil
|
||||
})
|
||||
}
|
||||
```
|
||||
This plugin already imports `golem15.user`'s models transitively (`Requires() []string { return []string{"golem15.user"} }`, line 39) — the new listener import direction (fonoteka → user) matches this existing `Requires` declaration; the reverse import must never appear (RESEARCH.md architecture map).
|
||||
|
||||
---
|
||||
|
||||
### `plugins/golem15/fonoteka/routes.go` (extend real handlers into existing empty groups)
|
||||
**Analog (self):** existing file (full file, 35 lines) — the `jwt_locale` empty group (line 23) and there being no `tokens` group yet are exactly what this phase fills in:
|
||||
```go
|
||||
r.Group("/_fonoteka/api/v1", surf.Use("jwt.auth"), func(g pact.Router) {
|
||||
g.Get("/me/locale", meLocale.Get)
|
||||
g.Put("/me/locale", meLocale.Put)
|
||||
})
|
||||
r.Group("/_fonoteka/api/v1", surf.Use("jwt.auth", "inv.must-change-password"), func(g pact.Router) {
|
||||
g.Post("/tokens", tokenAPI.Mint)
|
||||
g.Get("/tokens", tokenAPI.List)
|
||||
g.Delete("/tokens/{id}", tokenAPI.Revoke)
|
||||
})
|
||||
```
|
||||
Confirms manifest fixture paths already recorded (`GET|PUT /_fonoteka/api/v1/me/locale`, `POST|GET /_fonoteka/api/v1/tokens`, `DELETE /_fonoteka/api/v1/tokens/{id}`) — these route IDs already exist as `pending` entries in `parity/manifest.yaml` (see Shared Patterns / Parity below), so route paths must match byte-for-byte.
|
||||
|
||||
---
|
||||
|
||||
### `plugins/golem15/fonoteka/controllers/api/token_api_controller.go` and `me_locale_controller.go`
|
||||
**Analog:** `plugins/golem15/fonoteka/controllers/genre_controller.go` (full file) — same `func Xxx(app *backpack.App) http.HandlerFunc` factory shape, same `bouncer.User(r.Context())` + `app.Lookup[*gorm.DB]()` resolution, same `wire.WriteJSON`/`wire.WriteOpaque500` response helpers (aliased locally as `writeJSON`/`writeOpaque500`, lines 148-154). Token CRUD must scope every query by `WHERE user_id = ?` (owner-scoped, no-leak-404 pattern per RESEARCH.md's ASVS V4 row) exactly like `ListGenres` scopes by `classes.ResolveActiveCollection(r.Context(), tx, user.ID)` (lines 78-83).
|
||||
|
||||
---
|
||||
|
||||
### `plugins/golem15/fonoteka/classes/auth/api_token_manager.go`
|
||||
**Analog:** `plugins/golem15/fonoteka/classes/auth/token_guard.go` (same package, full file, 89 lines) — reuse the exact `sha256.Sum256` + `hex.EncodeToString` hashing convention (lines 43-44) so mint and verify never diverge in hash format (RESEARCH.md Anti-Pattern, "never store the raw secret"). See RESEARCH.md's `MintPersonalToken` Code Example (lines 427-449) for the full mint algorithm (`inv_` prefix, `crypto/rand` 32 bytes, `base64.RawURLEncoding`).
|
||||
|
||||
---
|
||||
|
||||
### `bouncer/mint.go`, `bouncer/refresh.go` (service, request-response)
|
||||
**Analog:** `bouncer/jwt.go` (full file, 192 lines) — `Verify` (lines 114-132) is the parser-construction idiom to extend:
|
||||
```go
|
||||
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
|
||||
})
|
||||
```
|
||||
For `Refresh`, swap `jwt.WithExpirationRequired()` for `jwt.WithoutClaimsValidation()` — see RESEARCH.md's fully-worked `Refresh` function (lines 236-263) which is the direct extension of this exact parser-construction pattern. `mapJWTError` (lines 167-185) is the error-classification idiom to extend for refresh-specific errors ("Could not refresh token", "The token has been blacklisted"). `write401` (lines 187-191) is the exact JSON 401 body shape (`{"error":true,"message":...}`) already used everywhere in `bouncer` — reuse verbatim, do not invent a new error envelope for refresh/mint failures.
|
||||
|
||||
---
|
||||
|
||||
### `bouncer/blacklist.go` (Store interface + Postgres impl)
|
||||
**Analog:** `summercms.go/surf/limiter_store.go` (full file, 93 lines) — the `Store` interface + concrete implementation pairing:
|
||||
```go
|
||||
type Store interface {
|
||||
Attempt(key string, max int, decay time.Duration) (allowed bool, attempts int, retryAfter time.Duration)
|
||||
}
|
||||
type MemoryStore struct { /* mutex-guarded, lazy expiry */ }
|
||||
func (s *MemoryStore) Attempt(key string, max int, decay time.Duration) (bool, int, time.Duration) { ... }
|
||||
```
|
||||
Port this exact interface-then-struct shape for blacklist:
|
||||
```go
|
||||
type BlacklistStore interface {
|
||||
Add(jti string, expiresAt, validUntil time.Time) error
|
||||
IsBlacklisted(jti string) (bool, error)
|
||||
Sweep(now time.Time) error
|
||||
}
|
||||
```
|
||||
`NewMemoryStore(sweep time.Duration)`'s background-sweep-goroutine convention (lines 32-42, `NewMemoryStore`/`loop`/`purge`) is the direct template for the Postgres blacklist's own periodic sweep, substituting a `DELETE FROM ... WHERE expires_at < ?` query for the in-memory `delete(s.entries, k)` loop. The lazy-expiry-on-read guarantee (`Attempt`, lines 71-93: check-and-expire happens inside the same locked/transactional operation, never a separate step) is the concurrency contract `IsBlacklisted` must preserve too (Pitfall 2: grace-period gate, `now > valid_until`, not mere row existence).
|
||||
|
||||
---
|
||||
|
||||
### `bouncer/context.go` (extend: `Principal.PreferredLocale`)
|
||||
**Analog (self):** existing file (full file, 51 lines) — add the field to the existing struct literal, no new context-key machinery needed:
|
||||
```go
|
||||
type Principal struct {
|
||||
ID uint
|
||||
MustChangePassword bool
|
||||
PreferredLocale string // NEW
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `surf/locale_from_principal.go` (new middleware)
|
||||
**Analog:** `plugins/golem15/fonoteka/middleware/must_change_password.go` (full file, 25 lines) for the "read `bouncer.User(r.Context())`, act, `next.ServeHTTP`" shape:
|
||||
```go
|
||||
func MustChangePassword(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := bouncer.User(r.Context())
|
||||
if ok && user.MustChangePassword { ... return }
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
```
|
||||
And `summercms.go/surf/router.go:650` (`locale` middleware) + `summercms.go/towel/context.go` (`WithLocale`/`Locale`, lines 56-64) for the context-rewrite half. RESEARCH.md's Pattern 3 (lines 309-329) gives the exact worked implementation:
|
||||
```go
|
||||
func LocaleFromPrincipal(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if p, ok := bouncer.User(r.Context()); ok && p.PreferredLocale != "" {
|
||||
r = r.WithContext(towel.WithLocale(r.Context(), p.PreferredLocale))
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `lagoon/validate.go` (extend `validateField` switch)
|
||||
**Analog (self):** existing file, `validateField` (read directly, the `switch name { case "nullable": ... case "unique": ... default: return nil, fmt.Errorf("lagoon: unrecognized validation rule %q", tok) }` structure). Add new `case "email":`, `case "confirmed":`, `case "different":` branches inside the same switch, following the existing `case "in":`/`case "unique":` style (append to `tags` for validator-delegated rules; do custom map-lookup logic inline for `confirmed`/`different` against the full `values map[string]any` already passed into `validateField`). This is a same-function, additive change — do not create a parallel validation path.
|
||||
|
||||
---
|
||||
|
||||
### `fonoteka.go/parity/manifest.yaml`, `capture-rules.yaml`, fixtures
|
||||
**Analog:** existing `manifest.yaml` entries for `me/locale` (lines 14-49) and `tokens` (lines 1611-1667) — these already exist as `status: pending` and must flip to `ported` once the routes exist and fixtures are recorded (do not create new manifest entries for these five routes; edit the existing ones' `status`). For the fifteen new `/_user/api/v1` routes, add new entries following the exact same block shape (`id`, `method`, `path`, `auth_group`, `status`, `identities`, `cases[].fixture`).
|
||||
|
||||
**capture-rules.yaml analog** (lines 1-20, already present): the `POST /_user/api/v1/login` capture rule already exists (`as: jwt:alice`) — extend this file with capture rules for `register`, `refresh`, `activate-by-code` (each also mints a token per D-11/Pitfall 3) using the identical `capture: [{from: response.json, path: $.token, as: jwt:..., category: jwt}]` shape.
|
||||
|
||||
**Fixture file analog:** `parity/fixtures/routes/POST___fonoteka_api_v1_genres_jwt.yaml` (full file, 22 lines) for the single-route fixture shape (`version`, `name`, `steps[].request/response`), and `parity/fixtures/nuxt/nuxt-browse.yaml` (read in full, 3394 lines total — only the first ~50 steps needed as a template) for the new `nuxt-auth.yaml` flow fixture D-14 requires: each step is `{id, request: {method, path, headers, body}, response: {status, headers, body}}`, with `capture:` blocks on any step whose response mints a reusable value (`jwt:alice`, `id:*` placeholders). The existing login step in `nuxt-browse.yaml` (id "5") is the literal template for `nuxt-auth.yaml`'s first step, including the exact `{{jwt:alice}}`/`{{id:token}}` placeholder syntax and the full `getApiArray` base-payload JSON shape (`permissions`, `avatar`/`avatar_url` gravatar fallback, `has_self_set_password`, `feedback_widget_hidden`, `organisation_id/role`, `must_change_password`, `preferred_locale`) that the new `fetch`/`register`/`update` handlers must reproduce byte-for-byte.
|
||||
|
||||
## Shared Patterns
|
||||
|
||||
### JSON response envelope
|
||||
**Source:** `summercms.go/wire/response.go` (full file, 29 lines)
|
||||
**Apply to:** every new handler in both `golem15.user` and `golem15.fonoteka` controllers.
|
||||
```go
|
||||
func WriteJSON(w http.ResponseWriter, status int, v any) {
|
||||
var buf bytes.Buffer
|
||||
enc := json.NewEncoder(&buf)
|
||||
enc.SetEscapeHTML(false)
|
||||
_ = enc.Encode(v)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_, _ = w.Write(bytes.TrimSuffix(buf.Bytes(), []byte("\n")))
|
||||
}
|
||||
func WriteOpaque500(w http.ResponseWriter) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte(`{"error":true,"message":"Internal server error"}`))
|
||||
}
|
||||
```
|
||||
|
||||
### 401 unauthorized envelope
|
||||
**Source:** `summercms.go/bouncer/jwt.go:187-191` (`write401`)
|
||||
**Apply to:** any new guard/mint/refresh failure path.
|
||||
```go
|
||||
func write401(w http.ResponseWriter, message string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"error": true, "message": message})
|
||||
}
|
||||
```
|
||||
|
||||
### 423 must-change-password envelope (byte-identical, do not touch)
|
||||
**Source:** `fonoteka.go/plugins/golem15/fonoteka/middleware/must_change_password.go` (full file, 25 lines)
|
||||
**Apply to:** unchanged — reused verbatim by the Fonoteka JWT-authed group; the user plugin's own `change-password` handler must clear the flag using the same field name (`MustChangePassword`) this middleware reads.
|
||||
|
||||
### Guard/credential resolution
|
||||
**Source:** `summercms.go/bouncer/context.go` (full file) + `summercms.go/bouncer/guard.go` (full file)
|
||||
**Apply to:** every handler that needs "who is calling" (`bouncer.User(ctx)`) or "what credential" (`bouncer.Credential(ctx)`, used by `InvScope`).
|
||||
|
||||
### Named-bucket rate limiting
|
||||
**Source:** `summercms.go/surf/limiter.go` `RegisterBucket`/`Middleware` (full file, 183 lines) + `fonoteka.go/plugins/golem15/fonoteka/plugin.go` `Buckets()` (lines 102-149)
|
||||
**Apply to:** `golem15.user`'s new `user-api` bucket declaration (C-04: 120/min, key user id else IP) — copy the `surf.BucketProvider` interface implementation shape from fonoteka's `Buckets()` method verbatim, substituting the single `user-api` bucket definition.
|
||||
|
||||
### Store interface (generic "durable rate/limit/blacklist state") shape
|
||||
**Source:** `summercms.go/surf/limiter_store.go` (full file, 93 lines)
|
||||
**Apply to:** `bouncer.BlacklistStore` (new) and `golem15.user`'s `Throttle` service — both are "check + atomically mutate a durable counter/flag keyed by a string" problems with an identical shape to `surf.Store.Attempt`.
|
||||
|
||||
### Append-only migrations, never edit shipped files
|
||||
**Source:** `fonoteka.go/plugins/golem15/fonoteka/updates/10_album_slice.go` (ALTER pattern) + `fonoteka.go/plugins/golem15/user/updates/10_organisations.go` (citation-comment convention, lines 8-13)
|
||||
**Apply to:** every new column/table this phase adds to `users` or new tables (`user_throttle`, `jwt_blacklist`) — new files only, sequential `ID`, PHP source citation in a comment, symmetric `Migrate`/`Rollback`.
|
||||
|
||||
### Plugin capability wiring (`pact.Has*` interfaces)
|
||||
**Source:** `summercms.go/examples/hello/plugins/base/plugin.go` (full file, 55 lines) + `internal/build/stubs/plugin.tmpl` (full file)
|
||||
**Apply to:** `golem15.user/plugin.go`'s new `pact.HasLang`, `pact.HasMailTemplates`, `pact.HasCommands` conformance — copy the `var _ pact.HasX = (*Plugin)(nil)` compile-time assertion convention already used in every existing plugin.go in this codebase.
|
||||
|
||||
## No Analog Found
|
||||
|
||||
Files/capabilities with no close match in the codebase (planner should lean on RESEARCH.md's worked Code Examples instead):
|
||||
|
||||
| File | Role | Data Flow | Reason |
|
||||
|------|------|-----------|--------|
|
||||
| `bouncer/password.go` (bcrypt hash/verify/rehash wrapper) | service | transform | No password-hashing code exists anywhere in the codebase yet (Phase 3/6 shipped only JWT verification, never credential creation). RESEARCH.md's Standard Stack section documents the exact `golang.org/x/crypto/bcrypt` API to wrap (`GenerateFromPassword`/`CompareHashAndPassword`/`Cost`). |
|
||||
| `plugins/golem15/user/classes/codes.go` (constant-time reset/activation code compare + issued-at TTL) | service | CRUD | No `crypto/subtle` usage exists in the codebase; PHP's own comparison is plain `===` (D-15 is a wire-invisible hardening with no PHP or Go precedent to copy from directly — implement directly from RESEARCH.md D-15's algorithm description). |
|
||||
| Avatar multipart upload/remove HTTP handler wiring (`api_controller.go`'s `UploadAvatar`/`RemoveAvatar`) | controller | file-I/O | D-04 states explicitly this is the port's first HTTP multipart endpoint in the whole codebase — `lagoon/attach` provides the storage primitives (File, Thumb, bucket) but no existing handler composes `r.ParseMultipartForm` + `body.limit` + `attach.File` together yet. Compose from `surf/bodylimit.go`'s `body.limit:N` middleware + `lagoon/attach/file.go`/`thumb.go`'s primitives per RESEARCH.md's Architecture Patterns / Anti-Patterns sections. |
|
||||
| `bouncer/blacklist.go`'s Postgres-backed implementation (as opposed to the interface, which has a strong analog) | model+service | CRUD | `surf.Store`'s only concrete implementation today is `MemoryStore` (in-process); this phase's D-08 requires a Postgres-backed store, which has no existing GORM-backed `Store`-shaped implementation anywhere to copy the persistence half from. Interface shape: exact match to `surf.Store`. Implementation: net-new, follow RESEARCH.md Pitfall 2's two-field (`expires_at`, `valid_until`) schema. |
|
||||
|
||||
## Metadata
|
||||
|
||||
**Analog search scope:** `summercms.go/{bouncer,surf,festival,postcard,lagoon,lagoon/attach,wire,towel,pact,bonfire,internal/build/stubs,examples/hello}`, `fonoteka.go/{plugins/golem15/user,plugins/golem15/fonoteka,parity}`
|
||||
**Files scanned:** ~55 (read in full or via targeted grep+read), reported below by category — bouncer (4), surf (4), festival (1), postcard (1), lagoon incl. attach (3), wire (1), towel (1), examples/hello (2), internal/build/stubs (1), fonoteka.go user plugin (5), fonoteka.go fonoteka plugin (7), parity (4)
|
||||
**Pattern extraction date:** 2026-09-22
|
||||
Reference in New Issue
Block a user