Files
summercms/.planning/phases/07-user-plugin-and-authentication/07-PATTERNS.md
2026-09-22 11:51:06 +02:00

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