Files
summercms/.planning/phases/07-user-plugin-and-authentication/07-02-PLAN.md
2026-09-22 12:37:08 +02:00

45 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, user_setup, must_haves
phase plan type wave depends_on files_modified autonomous requirements user_setup must_haves
07-user-plugin-and-authentication 02 execute 2
07-01
../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/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/classes/events.go
../fonoteka.go/plugins/golem15/user/classes/events_test.go
../fonoteka.go/plugins/golem15/user/classes/mail.go
../fonoteka.go/plugins/golem15/user/classes/mail_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
true
AUTH-01
AUTH-02
truths artifacts key_links
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), per D-16
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)
OAuthProviders always returns {"success":true,"providers":[]} since Płytarium configures no OAuth providers; the shape is always an array, never null, per D-03
The /_user/api/v1 group never mounts PIN login, device auth, 2FA, GET /api/user/batch or GET /_user/activate/{id} routes -- no 501 shells, columns stay in the schema, and login's success body never includes a two_factor_required key, per D-05
golem15.user.jwt.* config keys carry PHP's library defaults (ttl 60, refresh_ttl 20160, blacklist_grace 0, leeway 0) in the plugin's own config.yaml, and fonoteka.go's app-level config/golem15.user.yaml overrides them to Płytarium's real values (1440, 43200, 10s), per D-10
Login restores a soft-deleted user and sends mail.reactivate as PHP's afterLogin() does; is_guest rows are refused login (guest conversion dropped); register with an existing email gets the normal unique-email 422, per D-17
path provides
../fonoteka.go/plugins/golem15/user/models/user.go 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
path provides
../fonoteka.go/plugins/golem15/user/classes/throttle.go CheckAndRecordLogin(ctx, db, user, ip, ok) error per Pitfall 5
path provides
../fonoteka.go/plugins/golem15/user/controllers/api_controller.go login, logout, fetch, refresh, register, oauth-providers handlers
path provides
../fonoteka.go/plugins/golem15/user/classes/events.go GetApiArrayEvent (festival.Collectable) and RegisterEvent
from to via pattern
../fonoteka.go/plugins/golem15/fonoteka/plugin.go ../fonoteka.go/plugins/golem15/user/classes GetApiArrayEvent app.Events.Listen[*classes.GetApiArrayEvent] registered in fonoteka's Boot golem15.user.getApiArray|GetApiArrayEvent
from to via pattern
../fonoteka.go/plugins/golem15/user/controllers/api_controller.go login handler ../fonoteka.go/plugins/golem15/user/classes/throttle.go 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.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>

@.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, blacklist_sweep_interval: 10m}` (minutes/minutes/seconds/seconds/duration, D-10 — `secret` key already present, keep it; `blacklist_sweep_interval` is new, consumed by Task 2's periodic sweep goroutine), `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/classes/mail.go, ../fonoteka.go/plugins/golem15/user/classes/mail_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/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), summercms.go/postcard/mailer.go (lines 52-53: Send performs no locale selection -- the caller must pass the full dotted name including any -en suffix, per C-05/P4 D-08), summercms.go/surf/limiter_store.go (MemoryStore.loop/purge -- the ticker+stop-channel background-sweep convention this task's blacklist sweep goroutine mirrors), .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). - Test (mail template naming, C-05/P4 D-08): `mailTemplate("golem15.user::mail.reactivate", "pl")` returns `"golem15.user::mail.reactivate"` unchanged; `mailTemplate("golem15.user::mail.reactivate", "en")` returns `"golem15.user::mail.reactivate-en"`; an empty locale behaves like `"pl"` (the base name, no suffix). - Test (blacklist sweep): constructing the plugin's Boot with a short `blacklist_sweep_interval` (e.g. 50ms, test-only override) and a blacklist row whose `expires_at` is already past shows the row gone from `jwt_blacklist` after waiting slightly longer than the interval; a non-positive interval disables the goroutine entirely (no ticker created), mirroring `surf.MemoryStore`'s own precedent. 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 `classes/mail.go`: `func mailTemplate(base, locale string) string` -- the C-05/P4 D-08 caller-picks-the-suffix convention as a single reusable helper: returns `base + "-en"` when `locale == "en"`, else `base` unchanged (Polish is the unsuffixed default, matching every other mail template already shipped). Also add `func resolveMailLocale(ctx context.Context, user *models.User) string`: returns `user.PreferredLocale` when non-empty, else falls back to `towel.Locale(ctx)` (the request's header-resolved locale), else `""` (which `mailTemplate` treats as the Polish default). Every mail-sending call site in this plugin (Login's `mail.reactivate` below, Register's `mail.activate` in Task 3, ForgotPassword's `mail.restore` in 07-03) MUST route its template name through `mailTemplate(base, resolveMailLocale(ctx, user))` -- never a hardcoded base name -- so the locale actually sent matches the recipient's own `preferred_locale`, not always the Polish default.

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 using `mailTemplate("golem15.user::mail.reactivate", resolveMailLocale(ctx, user))` as the template name (07-03 authors the actual `.htm` files; 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, but the template-name computation through `mailTemplate`/`resolveMailLocale` must still run so the call site is correct once 07-03 lands the templates). 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)`.

Start a periodic blacklist-sweep goroutine in `Boot`, mirroring `surf.MemoryStore`'s `NewMemoryStore(sweep)`/`loop`/`purge` convention (`summercms.go/surf/limiter_store.go`) exactly: `backpack.App` has NO shutdown-context field or method today (confirmed by reading `backpack/app.go` in full -- do not invent one), so follow the SAME precedent `MemoryStore` already establishes in this codebase -- a `stop chan struct{}` field, `go loop()` started unconditionally when the configured interval is positive, and the goroutine simply lives for the process (its own `stop` channel is never closed in production, exactly like `MemoryStore`'s is not today; this is an accepted, already-precedented tradeoff, not a new gap). Concretely: read `golem15.user.jwt.blacklist_sweep_interval` (config key added in Task 1, default `10m`, a non-positive value disables the goroutine per the `MemoryStore` convention), `ticker := time.NewTicker(interval)`, and on each tick call `bl.Sweep(context.Background(), time.Now())`, logging (not failing Boot) on a sweep error. This closes the gap where `BlacklistStore.Sweep` (07-01) is implemented and tested but never actually invoked in production. 07-06 tests this by constructing the plugin with a short test-only interval and observing an expired row disappear, not by asserting the goroutine can be stopped (no stop-ability is required, matching `MemoryStore`'s own precedent).
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`) - `mailTemplate("golem15.user::mail.reactivate", "en")` returns `"golem15.user::mail.reactivate-en"`; `mailTemplate("golem15.user::mail.reactivate", "pl")` and `mailTemplate("golem15.user::mail.reactivate", "")` both return the base name unchanged - Login's reactivate-mail call site computes its template name through `mailTemplate(base, resolveMailLocale(ctx, user))`, not a hardcoded string literal - With `golem15.user.jwt.blacklist_sweep_interval` set short in a test, an expired `jwt_blacklist` row is gone after waiting past the interval 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; mail template names route through mailTemplate/resolveMailLocale; the blacklist sweep goroutine runs and actually removes expired rows. 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). - Test (feedback_widget_hidden): the base `apiArray` payload, even with no `golem15.fonoteka` listener registered at all, always contains `"feedback_widget_hidden": false`. - Test (mail template wiring): Register's user-mode branch computes its `mail.activate` template name through `mailTemplate(base, resolveMailLocale(ctx, user))`, not a hardcoded string. 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` via `mailTemplate("golem15.user::mail.activate", resolveMailLocale(ctx, user))` (07-03 authors the actual `.htm` files; this task may no-op if the mailer isn't resolvable yet, matching Task 2's guidance, but the template-name computation must still route through the helper) 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), PLUS the literal `"feedback_widget_hidden": false` (every recorded PHP payload carries this key because `golem15.feedback`'s own `getApiArray` listener always fires alongside `golem15.fonoteka`'s -- confirmed against `parity/fixtures/nuxt/nuxt-browse.yaml`; since the feedback plugin does not exist in this codebase yet, `golem15.user` ships the literal `false` default directly in the base payload with a comment that a future feedback-plugin phase replaces it with its own `golem15.user.getApiArray` listener, exactly the same seam `golem15.fonoteka`'s organisation/locale fields already use). 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 - The base `apiArray` payload contains the literal key `"feedback_widget_hidden": false` regardless of which listeners are registered - Register's `mail.activate` template-name computation calls `mailTemplate`/`resolveMailLocale`, not a hardcoded literal 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; the base payload always carries feedback_widget_hidden:false; mail.activate's template name routes through the locale-suffix helper.

<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