A user can log in with email+password and receive a wire-compatible JWT (D-06), and use it to fetch their own profile and log out
A user can register (auto-activation path) and immediately receive a token, per D-02's auto/not-required branch
Failed logins are throttled per (user_id, ip) with Winter's 5-attempt/15-minute algorithm, keyed and ordered exactly per Pitfall 5, and PHP's login() funnels every AuthException (bad creds, suspended, banned, unknown user) into the same generic 401 body (verified at controllers/ApiController.php:81-88, overriding the 07-CONTEXT.md paraphrase 'PHP's error bodies' as plural)
The fonoteka plugin's getApiArray listener adds organisation_id, organisation_role, must_change_password, preferred_locale without golem15.user importing golem15.fonoteka (AUTH-02)
The /_user/api/v1 group carries only throttle:user-api at the group level; login/logout/fetch/refresh/register each resolve auth per-handler via bouncer.NewJWTGuard(secret, users, blacklist) Bearer-only (D-01, D-09)
Full User model (name, surname, email, is_activated, codes+issued-at, has_self_set_password, marketing_consent, is_onboarded, organisation_id/role, preferred_locale, tokens_valid_after) with Fillable/Hidden/Rules
CheckAndRecordLogin called before AND after the password check per Pitfall 5
CheckAndRecordLogin
Land the working end-to-end session slice of the `golem15.user` plugin: the real `User`/`Throttle` models and their appended migrations, the failed-login throttle, and the login → fetch → refresh → logout loop plus registration, wired through the `bouncer` primitives from 07-01. After this plan, a real user (seeded in a test or via a future console command) can register or log in against Postgres, receive a PHP-wire-compatible JWT, call authenticated endpoints, and log out — the first working vertical slice of AUTH-01/AUTH-02.
Purpose: prove the JWT lifecycle, throttle and event-collect primitives against a real plugin boot and real handlers before the account-management slice (07-03) and the fonoteka-owned token/locale slice (07-04) build on top.
Output: a real users/user_throttle/jwt_blacklist schema, a real /_user/api/v1 route group with login/logout/fetch/refresh/register/oauth-providers, and the golem15.user.getApiArray fire-and-collect event consumed by golem15.fonoteka.
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/07-user-plugin-and-authentication/07-CONTEXT.md
@.planning/phases/07-user-plugin-and-authentication/07-RESEARCH.md
@.planning/phases/07-user-plugin-and-authentication/07-PATTERNS.md
@.planning/phases/07-user-plugin-and-authentication/07-01-SUMMARY.md
```go
func bouncer.Mint(secret, sub, issuerURL string, ttl time.Duration) (token, jti string, err error)
func bouncer.Refresh(secret, tokenString string, refreshTTL time.Duration, bl BlacklistStore, grace time.Duration, issuerURL string) (string, error)
type bouncer.BlacklistStore interface {
Add(ctx context.Context, jti string, expiresAt, validUntil time.Time) error
IsBlacklisted(ctx context.Context, jti string) (bool, error)
Sweep(ctx context.Context, now time.Time) error
}
func bouncer.NewPostgresBlacklist(db *sql.DB, table string) *PostgresBlacklist
func bouncer.NewJWTGuard(secret string, users UserProvider, bl BlacklistStore, cookieNames ...string) Guard
func bouncer.HashPassword(cost int, plain string) (string, error)
func bouncer.CheckPassword(hash, plain string) bool
func bouncer.NeedsRehash(hash string, configuredCost int) bool
type bouncer.Principal struct { ID uint; MustChangePassword bool; PreferredLocale string; TokensValidAfter time.Time }
```
`bouncer.Verify(tokenString, secret string) (sub string, err error)` is unchanged (Bearer parsing only reads `Authorization`; cookie fallback is a `NewJWTGuard` constructor option only golem15.fonoteka's `jwt.auth` registration uses in 07-04 — this plan's per-handler calls are Bearer-only, per D-09, matching `bouncer.Verify`'s existing behavior). `bouncer.VerifyClaims(tokenString, secret string) (sub string, iat, exp time.Time, jti string, err error)` (new in 07-01) is the exported claims-parsing entry point Logout/Refresh use to get `iat`/`exp`/`jti` outside the `bouncer` package.
From ../fonoteka.go/plugins/golem15/user/plugin.go (current): Boot registers "jwt" via reg.Register(p.ID(), "jwt", bouncer.NewJWTGuard(secret, classes.GormUsers{App: app})) — this call site's arity changes this plan (add bl and cookie names).
From ../fonoteka.go/plugins/golem15/fonoteka/plugin.go (current): Buckets() shows the exact map[string]surf.Bucket shape to copy for user-api.
Task 1: User/Throttle schema — models, appended migrations, config keys
../fonoteka.go/plugins/golem15/user/models/user.go,
../fonoteka.go/plugins/golem15/user/models/throttle.go,
../fonoteka.go/plugins/golem15/user/updates/202609220005_extend_users.go,
../fonoteka.go/plugins/golem15/user/updates/202609220006_create_user_throttle.go,
../fonoteka.go/plugins/golem15/user/updates/202609220007_create_jwt_blacklist.go,
../fonoteka.go/plugins/golem15/user/updates/user_session_test.go,
../fonoteka.go/plugins/golem15/user/config/config.yaml,
../fonoteka.go/config/golem15.user.yaml
../fonoteka.go/plugins/golem15/user/models/user.go (current 4-column stub), ../fonoteka.go/plugins/golem15/user/models/registry.go,
../fonoteka.go/plugins/golem15/user/updates/00_base.go, ../fonoteka.go/plugins/golem15/user/updates/10_organisations.go, ../fonoteka.go/plugins/golem15/user/updates/postgres_test.go, ../fonoteka.go/plugins/golem15/user/updates/organisations_test.go,
../fonoteka.go/plugins/golem15/fonoteka/updates/10_album_slice.go (execStmts ALTER pattern),
../fonoteka.go/plugins/golem15/fonoteka/models/api_token.go (Fillable/Hidden shape),
../fonoteka.go/plugins/golem15/fonoteka/models/artist.go (Fillable/Rules/BeforeValidate convention to copy),
summercms.go/compass/config_test.go (TestDottedPluginNamespaceAndTypedSection — the app-level `config/golem15.user.yaml` override precedence this plan relies on),
/media/nvme/dev/golem15/fonoteka/plugins/golem15/user/models/User.php (lines 1-158: $fillable/$hidden/$casts already read this session),
.planning/phases/07-user-plugin-and-authentication/07-CONTEXT.md (D-06, D-10, D-15, D-16, D-19, D-20, D-21)
- Test (migration): `TestExtendUsersMigration` runs `All()` against a fresh Postgres, asserts every new column exists (`name, surname, is_activated, activated_at, activation_code, activation_code_issued_at, reset_password_code, reset_password_code_issued_at, has_self_set_password, marketing_consent, is_onboarded, organisation_id, organisation_role, preferred_locale, tokens_valid_after, created_ip_address, last_ip_address`) and that `has_self_set_password` defaults `true` and `is_activated`/`marketing_consent`/`is_onboarded` default `false` for a freshly inserted row.
- Test (migration): `TestCreateUserThrottleMigration` asserts table `user_throttle` exists with columns `id, user_id, ip_address, attempts, is_suspended, suspended_at, is_banned, created_at, updated_at`.
- Test (migration): `TestCreateJwtBlacklistMigration` asserts table `jwt_blacklist` exists with columns `jti (primary key), expires_at, valid_until`.
- Test (migration): rollback of each new migration (in reverse ID order) leaves `users`/`user_throttle`/`jwt_blacklist` in their pre-migration shape (no orphaned columns/tables).
Extend `models/user.go`'s `User` struct with the fields listed in the migration behavior above (pointer types for nullable columns, matching the `Artist`/`ApiToken` pointer convention already in this codebase), keeping `ID/Email/Password/MustChangePassword` unchanged. Add `Fillable() []string` returning `{"name","surname","email","password","created_ip_address","last_ip_address","is_onboarded","preferred_locale","marketing_consent","organisation_id","organisation_role","must_change_password"}` (deliberately narrower than PHP's list — `username`/`pin`/`login`/GDPR-consent columns are out of Phase 7 scope: no ported handler reads or writes them). `Hidden() []string` returns `{"password","reset_password_code","activation_code"}`. `Rules() map[string]string` returns the register()-time rules only: `{"email": "required|between:6,255|email|unique:users", "password": "required|between:8,255|confirmed"}` (Laravel's `required:create`/`required_with` context modifiers have no `lagoon.Validate` equivalent and are dropped; `confirmed` alone already enforces the password/password_confirmation pairing). Keep the existing `TableName()`/`init(){ Register(User{}) }`.
Create `models/throttle.go`: `Throttle` struct (`ID uint`, `UserID uint`, `IPAddress *string`, `Attempts int`, `IsSuspended bool`, `SuspendedAt *time.Time`, `IsBanned bool`, `CreatedAt/UpdatedAt time.Time`), `TableName() string { return "user_throttle" }`, `init(){ Register(Throttle{}) }`.
Create three appended migration files in `updates/`, each a new `[]*gormigrate.Migration` var + its own `init(){ Register(...) }` (never touch `00_base.go`/`10_organisations.go`, P5 D-03):
- `202609220005_extend_users.go`: one migration, `ALTER TABLE users ADD COLUMN ...` for every new column via the `execStmts` helper convention from `10_album_slice.go` (copy that helper if not already package-visible), with `organisation_id` as `INTEGER REFERENCES golem15_user_organisations(id)`, booleans `NOT NULL DEFAULT`, everything else nullable. Rollback drops columns in reverse order.
- `202609220006_create_user_throttle.go`: `CREATE TABLE user_throttle (id SERIAL PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id), ip_address TEXT, attempts INTEGER NOT NULL DEFAULT 0, is_suspended BOOLEAN NOT NULL DEFAULT FALSE, suspended_at TIMESTAMPTZ, is_banned BOOLEAN NOT NULL DEFAULT FALSE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW())` plus a plain (non-unique) index on `(user_id, ip_address)` — Pitfall 5's NULL-ip fallback row rules out a unique constraint. Rollback drops the table.
- `202609220007_create_jwt_blacklist.go`: `CREATE TABLE jwt_blacklist (jti TEXT PRIMARY KEY, expires_at TIMESTAMPTZ NOT NULL, valid_until TIMESTAMPTZ NOT NULL)`. Rollback drops the table.
Extend `../fonoteka.go/plugins/golem15/user/config/config.yaml` (library defaults, matching PHP's `config()` fallback values read this session) with `jwt: {ttl: 60, refresh_ttl: 20160, blacklist_grace: 0, leeway: 0}` (minutes/minutes/seconds/seconds, D-10 — `secret` key already present, keep it), `activation: {require_activation: true, activate_mode: auto, reset_ttl_minutes: 60, activation_ttl_hours: 72}` (D-15), `registration: {allow_registration: true, use_register_throttle: true}`, `throttle: {attempt_limit: 5, suspension_minutes: 15, use_throttle: true}` (D-16), `password: {bcrypt_cost: 10, min_length: 8}` (D-19).
Create `../fonoteka.go/config/golem15.user.yaml` (new app-level dotted-namespace override file, per the `compass` convention confirmed in `TestDottedPluginNamespaceAndTypedSection`) with Płytarium's real values (D-10): `jwt: {ttl: 1440, refresh_ttl: 43200, blacklist_grace: 10}`. Do not put the JWT secret in this file — it stays env-only (`SUMMER_GOLEM15__USER__JWT__SECRET`).
go vet ./... && go test ./plugins/golem15/user/updates/... -run 'TestExtendUsersMigration|TestCreateUserThrottleMigration|TestCreateJwtBlacklistMigration'
- `gdb.Migrator().HasColumn("users", col)` is true for every column named in this task's behavior list, after `All()` migrates up
- A freshly inserted `users` row has `has_self_set_password = true` and `is_activated = false` by column default
- `user_throttle` and `jwt_blacklist` tables exist with exactly the columns specified
- Rolling back all three new migrations (reverse ID order) leaves `users` with no orphaned columns and drops both new tables
- `../fonoteka.go/config/golem15.user.yaml` exists and contains `jwt.ttl: 1440`, `jwt.refresh_ttl: 43200`, `jwt.blacklist_grace: 10`
All three migrations run up and down cleanly against a real Postgres; models.User/Throttle compile with the new columns; config.yaml and the new app-level override file carry every D-10/D-15/D-16/D-19 default and Płytarium value.
Task 2: Core session loop — throttle port, login/logout/fetch/refresh
../fonoteka.go/plugins/golem15/user/classes/throttle.go,
../fonoteka.go/plugins/golem15/user/classes/throttle_test.go,
../fonoteka.go/plugins/golem15/user/classes/user_lookup.go,
../fonoteka.go/plugins/golem15/user/controllers/api_controller.go,
../fonoteka.go/plugins/golem15/user/controllers/api_controller_test.go,
../fonoteka.go/plugins/golem15/user/routes.go,
../fonoteka.go/plugins/golem15/user/plugin.go
../fonoteka.go/plugins/golem15/fonoteka/controllers/genre_controller.go (handler-factory / writeJSON / write401 idioms to copy),
../fonoteka.go/plugins/golem15/fonoteka/classes/auth/token_guard.go (remoteIP helper to copy verbatim),
../fonoteka.go/plugins/golem15/fonoteka/plugin.go (Buckets() shape to copy for user-api),
summercms.go/bouncer/jwt.go, summercms.go/bouncer/mint.go, summercms.go/bouncer/refresh.go, summercms.go/bouncer/blacklist.go (all from 07-01),
summercms.go/wire/response.go,
/media/nvme/dev/golem15/fonoteka/plugins/golem15/user/controllers/ApiController.php (lines 42-174, 1412-1437: login/logout/fetch/refresh/authorize — already read this session),
/media/nvme/dev/golem15/fonoteka/vendor/winter/storm/src/Auth/Manager.php (lines 318-458: findThrottleByLogin/validateInternal),
.planning/phases/07-user-plugin-and-authentication/07-RESEARCH.md (Pitfall 5, the CheckAndRecordLogin code example)
- Test (throttle): 5 failed `CheckAndRecordLogin(ctx, db, user, ip, false)` calls for the same `(user, ip)` succeed (no error, `attempts` increments 1..5); the 6th returns a non-nil error and further attempts keep erroring until `suspended_at + 15m` has passed.
- Test (throttle): a row with `is_banned = true` always errors regardless of attempt count.
- Test (throttle): a successful login (`ok=true`) resets `attempts` to 0 and clears `is_suspended`.
- Test (throttle): an unknown login (no matching user row) never creates or touches a throttle row (Pitfall 5 — throttle keys off an existing user).
- Test (login handler, httptest): valid email+password returns 200 `{"token":"...","user":{...}}` with a token that decodes to `sub=`, `prv=a867434cbc213adfbe78a02bed7082a6bd99c883`, `iss` equal to the request's own full URL.
- Test (login handler): wrong password, throttled account, and unknown email all return the IDENTICAL body `{"error":true,"message":"Invalid email or password"}`, status 401 (Pitfall 5 / the ApiController.php:81-88 generic catch — not per-cause bodies).
- Test (logout handler): a valid bearer token returns 200 `{"message":"Logged out"}` and the SAME token immediately fails a subsequent `fetch` call with 401 `{"error":true,"message":"Unauthorized"}` (forever-blacklist).
- Test (fetch handler): valid bearer returns 200 `{"user":{...}}`; missing/invalid bearer returns 401 `{"error":true,"message":"Unauthorized"}`.
- Test (refresh handler): no token present returns 401 `{"error":"Token not found"}` (string `error`, no `message`/`msg` key — distinct envelope from login/logout/fetch); an expired-but-within-`refresh_ttl` token returns 200 `{"token":"..."}` with a NEW jti, and the OLD token then fails IsBlacklisted-gated auth; a token past its refresh window returns 401 `{"error":"Could not refresh token","msg":"..."}`.
- Test (group wiring): `/_user/api/v1` carries exactly `throttle:user-api` at the group level and no `jwt.auth`/`inv.must-change-password` (D-01); a boot-smoke test asserts this over the real route table (mirror the Phase 6 `TestAllRouteGroupsBoot` boot-probe idiom if the group is otherwise empty of a distinguishing assertion).
Create `classes/throttle.go`: `func CheckAndRecordLogin(ctx context.Context, db *gorm.DB, user *models.User, ip string, ok bool) error` implementing RESEARCH.md's `CheckAndRecordLogin` algorithm exactly — lookup `WHERE user_id = ? AND (ip_address = ? OR ip_address IS NULL)`, create-if-absent, check `is_banned` then `is_suspended && now < suspended_at+15m` (config `golem15.user.throttle.suspension_minutes`) BEFORE the caller's credential check has even run (the caller — `login()` — calls this ONCE to gate, then again after the password check to record the outcome; see below), increment/suspend at `attempt_limit` (config `golem15.user.throttle.attempt_limit`, default 5) on failure, reset on success. Honor `golem15.user.throttle.use_throttle` — when `false`, always return `nil` without touching the table (D-16). Reuse `remoteIP(r)`'s exact body from `fonoteka`'s `token_guard.go` (copy, do not cross-import — the user plugin does not depend on the fonoteka plugin).
Extend `classes/user_lookup.go`'s `GormUsers.FindByID`: populate the new `Principal` fields — `PreferredLocale: row.PreferredLocale`, `TokensValidAfter` from `row.TokensValidAfter` (zero `time.Time{}` when the column is `NULL`). Add `func LookupByEmail(ctx context.Context, db *gorm.DB, email string) (*models.User, error)` (plain `Where("email = ?", email).Take(...)`, `nil, nil` on not-found — soft-deleted rows ARE returned here, since `afterLogin`'s restore-on-login (D-17) needs to see them; do not add a `deleted_at IS NULL` filter to this specific lookup).
Create `controllers/api_controller.go` (package `controllers`, mirroring `genre_controller.go`'s `func Xxx(app *backpack.App) http.HandlerFunc` factory shape and `wire.WriteJSON`/local `writeJSON`/`writeOpaque500` aliases):
- `Login(app)`: parse `email`/`password` from the request body (JSON or form, matching `$request->get(...)`'s dual support — decode JSON body if `Content-Type` is `application/json`, else `r.ParseForm()`). Look up the user by email (`LookupByEmail`); if found, call `CheckAndRecordLogin(ctx, db, user, ip, false)` BEFORE checking the password to enforce the ban/suspend gate ahead of the credential check (Pitfall 5's ordering); if that errors, OR the user is nil, OR `user.IsGuest`-equivalent (skip — guest rows don't exist in Go's reduced schema, D-17 drops guest conversion entirely), OR `!bouncer.CheckPassword(user.Password, password)`, return the generic 401 body (record the failed attempt via `CheckAndRecordLogin(ctx, db, user, ip, false)` again ONLY when a user row was found — an unknown email never touches the throttle table). On success: if `user.DeletedAt` is set, restore it (`db.Unscoped().Model(user).Update("deleted_at", nil)`) and send `mail.reactivate` through the D-18 seam (07-03 wires the real mailer; this plan may leave a TODO-free no-op interface call since `postcard.Mailer` lookup can return "not configured" gracefully — do not block this task on mail; if the mailer isn't yet resolvable, skip sending rather than fail the login). Call `CheckAndRecordLogin(ctx, db, user, ip, true)` to clear the throttle. Silently rehash the password if `bouncer.NeedsRehash` is true (D-19). Mint via `bouncer.Mint(secret, strconv.FormatUint(uint64(user.ID),10), requestURL(r), ttl)` where `requestURL(r)` builds the full scheme+host+path URL of THIS request (Pitfall 3 — never a constant). Build the `getApiArray`-equivalent payload (Task 3 supplies the real `GetApiArrayEvent`; for this task, build the payload inline with the base fields RESEARCH.md's Pattern 2 lists, collecting through `app.Events.Collect` if `app.Events != nil`, else the base fields alone) and return `{"token": token, "user": payload}`, 200.
- `Logout(app)`: extract the bearer token, then `bouncer.VerifyClaims(token, secret)` for `sub/iat/exp/jti`, then `classes.GormUsers{App:app}.FindByID(ctx, id)` for the principal (the per-handler Bearer-only equivalent of `jwtGuard.Authenticate`, using `VerifyClaims` directly since the raw claims are needed too) — on any failure, `{"error":true,"message":"Unauthorized"}`,401. On success, forever-blacklist the presented token's jti: `blacklist.Add(ctx, jti, exp, time.Now())` (`validUntil=now` makes it immediately blacklisted); `{"message":"Logged out"}`,200.
- `Fetch(app)`: same per-handler guard call; success `{"user": payload}`,200; failure `{"error":true,"message":"Unauthorized"}`,401.
- `Refresh(app)`: extract the bearer token — Bearer-only, D-09 — via a small local, package-private Bearer-only extractor in this controller (do not import bouncer's unexported `bearerToken`; a two-line `strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")` copy is enough, matching the exact idiom already in `bouncer/jwt.go`'s `bearerToken`). If absent: `{"error":"Token not found"}`,401 (STRING error key, no `message`/`msg`). Otherwise call `bouncer.Refresh(secret, token, refreshTTL, blacklist, grace, requestURL(r))`; on error, `{"error":"Could not refresh token","msg": err.Error()}`,401; on success, `{"token": newToken}`,200.
- `OAuthProviders(app)`: always `{"success": true, "providers": []}`,200 (Płytarium configures no OAuth providers — D-03; the shape must stay a JSON array, not `null`, per `wire.Slice`).
Wire `routes.go`: `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.Get("/oauth-providers", controllers.OAuthProviders(p.app)) })` — `Register` is a Task 3 handler; declare its call site now, implement in Task 3 (interface-first ordering within this plan's own scope is fine since Task 3 immediately follows).
Extend `plugin.go`'s `Boot`: construct `bl := bouncer.NewPostgresBlacklist(sqlDB, "jwt_blacklist")` (resolve `*sql.DB` via `app.Lookup[*sql.DB]()`) and `app.Publish[bouncer.BlacklistStore](bl)`; change the existing `reg.Register(p.ID(), "jwt", bouncer.NewJWTGuard(secret, classes.GormUsers{App: app}))` call to `bouncer.NewJWTGuard(secret, classes.GormUsers{App: app}, bl, "token", "auth_token")` (D-09 cookie fallback belongs on the Registry-resolved "jwt" guard, used by `/_fonoteka/api/v1`'s `jwt.auth`; the user plugin's OWN per-handler calls inside `api_controller.go` construct a SEPARATE Bearer-only `bouncer.NewJWTGuard(secret, users, bl)` with no cookie names). Add `func (p *Plugin) Buckets() map[string]surf.Bucket` implementing `surf.BucketProvider`: `"user-api": {Max: 120, Decay: time.Minute, Key: func(r *http.Request) string { if sub, err := bouncer.Verify(bearerFrom(r), secret); err == nil { return "u:" + sub }; return surf.ClientIP(r, trusted) }}` (C-04 — a lightweight signature-only parse for keying, no DB hit; falls back to `ClientIP` on any failure including a missing header). Declare `var _ surf.BucketProvider = (*Plugin)(nil)`.
go vet ./... && go test ./plugins/golem15/user/... -run 'TestCheckAndRecordLogin|TestLogin|TestLogout|TestFetch|TestRefresh' -short
- Valid credentials return 200 `{"token":"...","user":{...}}`; the token decodes to `sub=` and `prv=a867434cbc213adfbe78a02bed7082a6bd99c883`
- Wrong password, a throttled account, and an unknown email all return the byte-identical body `{"error":true,"message":"Invalid email or password"}`, status 401
- `POST logout` followed immediately by `GET fetch` with the SAME token returns 401 `{"error":true,"message":"Unauthorized"}` on the fetch call
- `POST refresh` with no token returns 401 `{"error":"Token not found"}` (no `message`/`msg` key); a past-refresh-window token returns 401 `{"error":"Could not refresh token","msg":"..."}`
- The 6th failed login for the same `(user,ip)` within 15 minutes is rejected by `CheckAndRecordLogin` before any password comparison runs
- `surf.RouteInfo` for every `/_user/api/v1` route lists `Middleware == ["throttle:user-api"]` (no `jwt.auth`, no `inv.must-change-password`)
login/logout/fetch/refresh all pass their httptest behaviors above against a real Postgres-backed plugin boot; the throttle suspends after 5 failed attempts per (user,ip); the group carries only throttle:user-api.
Task 3: Registration slice — register handler, getApiArray event, fonoteka listener
../fonoteka.go/plugins/golem15/user/classes/events.go,
../fonoteka.go/plugins/golem15/user/classes/events_test.go,
../fonoteka.go/plugins/golem15/user/controllers/api_controller.go,
../fonoteka.go/plugins/golem15/user/controllers/api_controller_test.go,
../fonoteka.go/plugins/golem15/user/routes.go,
../fonoteka.go/plugins/golem15/user/plugin.go,
../fonoteka.go/plugins/golem15/fonoteka/plugin.go
summercms.go/festival/bus.go, summercms.go/examples/hello/plugins/greeter/plugin.go (HelloEvent — Collectable/Handleable idiom),
../fonoteka.go/plugins/golem15/fonoteka/Plugin.php lines 224-253 (already read this session — the exact flat-merge listener shape and comment about halt=false/array_merge order),
/media/nvme/dev/golem15/fonoteka/plugins/golem15/user/controllers/ApiController.php lines 251-346 (register — already read this session, including the ApplicationException/SafeExceptionResponse interaction),
/media/nvme/dev/golem15/fonoteka/plugins/golem15/user/Plugin.php lines 316-364 (getApiArray base payload — already read this session),
/media/nvme/dev/golem15/fonoteka/.../SafeExceptionResponse.php (already read this session),
.planning/phases/07-user-plugin-and-authentication/07-CONTEXT.md (D-02, A4)
- Test (events): firing `GetApiArrayEvent{User: u}` through `app.Events.Collect` with a fonoteka-style listener registered returns a map containing the listener's keys merged onto the base map; a second listener registered at a later priority overwrites a colliding key (later wins, matching PHP `array_merge` order per RESEARCH Pattern 2).
- Test (events, import direction): a `go list -deps` (or an equivalent static check) over `plugins/golem15/user/...` contains no `plugins/golem15/fonoteka` import (AUTH-02's "without golem15.user importing golem15.fonoteka").
- Test (register handler): valid registration data with `activate_mode=auto` (the config default) returns 200 `{"token":"...","user":{...}}`, and the new user can immediately `fetch` with that token.
- Test (register handler): a request missing `email` returns 422 `{"error":"","errors":{"email":[...]}}`.
- Test (register handler): `allow_registration=false` (test override) returns 500 `{"error":"Internal server error"}` — NOT the literal "Registrations are currently disabled." text — reproducing `SafeExceptionResponse`'s production-mode (app.debug=false) degradation of any non-Validation/Http/Authentication exception, confirmed by reading the trait this session; the literal text only surfaces when `app.debug=true` (also test this branch).
- Test (getApiArray via fonoteka listener): registering `golem15.user` + `golem15.fonoteka` together and firing `GetApiArrayEvent` for a user with `OrganisationID`/`OrganisationRole`/`MustChangePassword`/`PreferredLocale` set produces a payload containing exactly those four extra keys with those values (AUTH-02's acceptance shape).
Create `classes/events.go`: `type GetApiArrayEvent struct { User *models.User; data map[string]any }` with `func (e *GetApiArrayEvent) Collected() map[string]any` (lazy-init `data`, per RESEARCH Pattern 2 — festival.Collectable). Add `type RegisterEvent struct { User *models.User }` (no `Collected()` — a plain `Fire`-only event mirroring PHP's `Event::fire('golem15.user.register', [$user, $data])`; D-02/plan-table says register this fire-and-forget event with no consumers this phase).
In `controllers/api_controller.go`, add `Register(app)`: read config `golem15.user.registration.allow_registration`/`use_register_throttle`. If registration is disabled OR the caller's IP has 3+ prior registrations in the last 60 minutes when throttling is on (D-02 — count `created_ip_address` matches on `users` in the last hour, client IP via the Phase 6 trusted-proxy `surf.ClientIP` function), build the SafeExceptionResponse-equivalent: if `app.Config.Bool("app.debug")` is true, return the literal PHP message (`"Registrations are currently disabled."` / `"Registration is throttled. Please try again later."`) at status 500 with body `{"error": "<message>"}`; if false (production, matches the pinned parity recorder), return `{"error":"Internal server error"}`,500 — do not leak the specific cause in production, matching `SafeExceptionResponse::safeExceptionMessage`'s exact behavior read this session. Otherwise validate the request body against `models.User{}.Rules()` via `lagoon.Validate` (using the extended `email`/`confirmed` tokens from 07-01); on failure, `{"error": <first message from the flattened errors map, any deterministic pick>, "errors": <full map>}`,422. On success: hash the password (`bouncer.HashPassword`), set `CreatedIPAddress`/`LastIPAddress` from `surf.ClientIP`, insert the row via `lagoon.Fill` against `Fillable()` (never raw `db.Create(&input)`), fire `RegisterEvent` (best-effort, ignore its error per "no consumers yet"). Then branch on `activate_mode`: `auto` or `!require_activation` → mint a token exactly like `Login` does and return `{"token":..., "user": <apiArray>}`,200; `user` → send `mail.activate` (07-03 wires the real send; this task may no-op if the mailer isn't resolvable yet, matching Task 2's guidance) and return `{"message":"Activation email sent"}`,200; `admin` → return `{}` (empty JSON object, NOT `null` or `[]`), 200.
Build the shared `apiArray(ctx, app, user *models.User) (map[string]any, error)` helper (used by `Login`/`Fetch`/`Register`/future 07-03 handlers): base fields exactly per `Plugin.php:329-359` — `id, name, surname, email, is_activated, permissions: []string{}, avatar: nil, avatar_url: nil, has_avatar: false, marketing_consent, groups: map[string]string{}, role: nil, is_onboarded, has_self_set_password` (A4 — `permissions`/`groups`/`role` stub empty/nil since no Go RBAC model exists; `avatar`/`avatar_url`/`has_avatar` are wired for real once 07-03 lands the attachment — this task's stub values must still be present as literal keys so the payload shape is stable across plans). Then `if app.Events != nil { extra, _ := app.Events.Collect(ctx, &classes.GetApiArrayEvent{User: user}); for k, v := range extra { payload[k] = v } }` (array_merge order — later listener wins, per RESEARCH Pattern 2).
In `../fonoteka.go/plugins/golem15/fonoteka/plugin.go`'s `Boot`, add the listener (only when `app.Events != nil`): `app.Events.Listen[*userclasses.GetApiArrayEvent]("golem15.fonoteka", func(ctx context.Context, e *userclasses.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 })` (import alias `userclasses "git.golem15.com/golem15/fonoteka/plugins/golem15/user/classes"` — `golem15.fonoteka` already `Requires()` `golem15.user`, so this import direction is already established; `golem15.user` must never import `golem15.fonoteka` back).
Confirm `../fonoteka.go/config/http.yaml`'s `cors.paths` already lists `_user/api/*` (it does, verified this session) — add a one-line assertion in `api_controller_test.go` (a plain string-contains check over the loaded CORS config, or reuse whatever CORS-config test helper 06-03 shipped) rather than re-wiring anything, since D-01's CORS requirement is already satisfied.
go vet ./... && go test ./plugins/golem15/... -run 'TestGetApiArray|TestRegister|TestRegisterEvent' -short
- `activate_mode=auto` registration returns 200 `{"token":"...","user":{...}}` and the returned token authenticates an immediate `fetch`
- `allow_registration=false` returns 500 `{"error":"Internal server error"}` when `app.debug=false`, and the literal `"Registrations are currently disabled."` text only when `app.debug=true`
- A `GetApiArrayEvent` collected with a `golem15.fonoteka`-style listener registered contains `organisation_id`, `organisation_role`, `must_change_password`, `preferred_locale` as the ONLY extra keys beyond the base payload
- `go list -deps ./plugins/golem15/user/...` contains no `plugins/golem15/fonoteka` path segment
- A missing `email` field in the register request returns 422 with `errors.email` present
register() reproduces all three D-02 branches plus the confirmed SafeExceptionResponse degradation; GetApiArrayEvent is collected across the golem15.user→golem15.fonoteka boundary with the exact four extra keys; golem15.user has zero import of golem15.fonoteka.
<threat_model>
Trust Boundaries
Boundary
Description
Client → login/register
Untrusted email/password crosses into credential verification and account creation
Client → refresh/logout
Untrusted bearer token crosses into the sliding-refresh and forever-blacklist paths
STRIDE Threat Register
Threat ID
Category
Component
Disposition
Mitigation Plan
T-07-01
Spoofing / Elevation of Privilege
Logout → jwt_blacklist
mitigate
Logout forever-blacklists the presented jti; Fetch immediately rejects a reused, just-logged-out token via the guard's blacklist check (07-01)
T-07-04
Spoofing (credential stuffing)
Login
mitigate
Two independent layers: throttle:user-api bucket (120/min, C-04) in front of the handler, and CheckAndRecordLogin's per-(user,ip) 5-attempt/15-minute suspend (D-16) inside it — neither substitutes for the other
T-07-07
Tampering
Empty JWT secret
accept (already mitigated)
classes.JWTSecret already fails boot on empty (unchanged this plan)
T-07-12
Information Disclosure
register()'s disabled/throttled branches
mitigate
Reproduces SafeExceptionResponse's production-mode message hiding (generic "Internal server error" under app.debug=false) instead of leaking the specific cause — matches PHP's actual deployed behavior, verified by reading the trait this session
</threat_model>
`go vet ./...` and `go test ./... -short` green in `fonoteka.go`. A manual httptest sequence — register → fetch → logout → fetch-with-same-token (expect 401) — passes end to end against a real (testcontainers) Postgres.
<success_criteria>
A user can register, log in, fetch their own profile (with organisation/locale fields present via the fonoteka listener), refresh a near-expired token, and log out with the old token permanently rejected afterward — the full AUTH-01/AUTH-02 session loop working against real Postgres.
</success_criteria>
Create `.planning/phases/07-user-plugin-and-authentication/07-02-SUMMARY.md` when done