docs(07): revise plans after checker review

This commit is contained in:
Jakub Zych
2026-09-22 12:37:08 +02:00
parent 57745e32a2
commit 3aed5c88c3
6 changed files with 65 additions and 22 deletions

View File

@@ -18,6 +18,8 @@ files_modified:
- ../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
@@ -31,9 +33,13 @@ must_haves:
truths:
- "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)"
- "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"
artifacts:
- path: "../fonoteka.go/plugins/golem15/user/models/user.go"
provides: "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"
@@ -140,7 +146,7 @@ From ../fonoteka.go/plugins/golem15/fonoteka/plugin.go (current): `Buckets()` sh
- `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).
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`).
</action>
@@ -163,6 +169,8 @@ From ../fonoteka.go/plugins/golem15/fonoteka/plugin.go (current): `Buckets()` sh
../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,
@@ -176,6 +184,8 @@ From ../fonoteka.go/plugins/golem15/fonoteka/plugin.go (current): `Buckets()` sh
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)
</read_first>
<behavior>
@@ -189,14 +199,18 @@ From ../fonoteka.go/plugins/golem15/fonoteka/plugin.go (current): `Buckets()` sh
- 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.
</behavior>
<action>
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 (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.
- `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.
@@ -205,6 +219,8 @@ From ../fonoteka.go/plugins/golem15/fonoteka/plugin.go (current): `Buckets()` sh
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).
</action>
<verify>
<automated>go vet ./... && go test ./plugins/golem15/user/... -run 'TestCheckAndRecordLogin|TestLogin|TestLogout|TestFetch|TestRefresh' -short</automated>
@@ -216,8 +232,11 @@ From ../fonoteka.go/plugins/golem15/fonoteka/plugin.go (current): `Buckets()` sh
- `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
</acceptance_criteria>
<done>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.</done>
<done>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.</done>
</task>
<task type="auto" tdd="true">
@@ -246,13 +265,15 @@ From ../fonoteka.go/plugins/golem15/fonoteka/plugin.go (current): `Buckets()` sh
- Test (register handler): a request missing `email` returns 422 `{"error":"<first message>","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.
</behavior>
<action>
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.
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). 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).
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).
@@ -267,8 +288,10 @@ From ../fonoteka.go/plugins/golem15/fonoteka/plugin.go (current): `Buckets()` sh
- 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
</acceptance_criteria>
<done>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.</done>
<done>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.</done>
</task>
</tasks>