36 KiB
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:
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):
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):
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):
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):
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:
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).
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]):
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:
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):
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:
{
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:
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:
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:
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:
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:
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:
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:
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:
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.
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.
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