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

36 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
07-user-plugin-and-authentication 03 execute 3
07-02
../fonoteka.go/plugins/golem15/user/classes/codes.go
../fonoteka.go/plugins/golem15/user/classes/codes_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/console/require_password_change.go
../fonoteka.go/plugins/golem15/user/console/require_password_change_test.go
../fonoteka.go/plugins/golem15/user/views/mail/activate.htm
../fonoteka.go/plugins/golem15/user/views/mail/activate-en.htm
../fonoteka.go/plugins/golem15/user/views/mail/restore.htm
../fonoteka.go/plugins/golem15/user/views/mail/restore-en.htm
../fonoteka.go/plugins/golem15/user/views/mail/reactivate.htm
../fonoteka.go/plugins/golem15/user/views/mail/reactivate-en.htm
../fonoteka.go/plugins/golem15/user/views/mail/layouts/user.htm
../fonoteka.go/plugins/golem15/user/plugin.go
../fonoteka.go/plugins/golem15/user/config/config.yaml
true
AUTH-01
truths artifacts key_links
A user can request a password reset by email (always the enumeration-safe 200), reset it with the {id}!{code} code, and log in with the new password
A user can activate an account via the authenticated activate route (fire-and-forget, always 200, per ApiController.php:180-196's missing if-check) and via the public activate-by-code route (which DOES check the result, per ApiController.php:208-245)
An authenticated user can update their name/surname/email, change their password (current-password verified, D-20 tokens-valid-after invalidation with the presenting token exempted), upload/remove an avatar, and toggle marketing consent
Reset and activation codes compare in constant time and expire per a configured TTL with a null-issued-at cutover row counted as issued now (D-15)
mail.activate/mail.restore/mail.reactivate render and send through postcard.Send, with the caller picking the -en sibling from preferred_locale via mailTemplate/resolveMailLocale (C-05)
change-password ports the normal (self-set-password) branch only; a has_self_set_password=false row 500s with an opaque body rather than being silently treated as self-set, since no Go path can create such a row and the social-login OTP branch is deferred, per D-03
Avatar upload and remove are ported on the Phase 5 attachment machinery (system_files, Thumb): payload avatar/avatar_url (128 thumb)/has_avatar are real, bounded by the existing per-group MaxBytesReader upload cap, per D-04
Mail is sent inline through postcard.Send behind a small seam Phase 11 later swaps for a River job; forgot-password always returns its enumeration-safe 200 body and logs a send failure instead of surfacing it, per D-18
path provides
../fonoteka.go/plugins/golem15/user/classes/codes.go IssueResetCode/IssueActivationCode/VerifyResetCode/VerifyActivationCode with constant-time compare and TTL
path provides
../fonoteka.go/plugins/golem15/user/console/require_password_change.go user:require-password-change <email> console command (D-21)
path provides
../fonoteka.go/plugins/golem15/user/views/mail/{activate,restore,reactivate}(-en).htm the three mail templates plus layout, registered via pact.HasMailTemplates
from to via pattern
../fonoteka.go/plugins/golem15/user/controllers/api_controller.go change-password handler bouncer.Principal.TokensValidAfter / VerifyClaims cutover set to presentingIat-1s so the calling device is not logged out mid-session (D-20, Open Question 1) TokensValidAfter
from to via pattern
../fonoteka.go/plugins/golem15/user/controllers/api_controller.go avatar handlers summercms.go/lagoon/attach File/Thumb attach.File row per upload, f.Thumb(ctx, bucket, 128, 128, "auto") for avatar_url attach.(File|Thumb)
Complete the `golem15.user` plugin's account-management surface: forgot/reset password, activation (both the authenticated and public-code routes), profile update, change-password (with the D-20 session-invalidation exemption), avatar upload/remove, and marketing consent — plus the mail templates and the `must_change_password` console command those flows need.

Purpose: this is the last user-plugin-owned slice; after this plan every /_user/api/v1 route in D-01 has a real handler and golem15.user's own code is feature-complete for Phase 7. Output: classes/codes.go, the remaining api_controller.go handlers, three mail templates, and the user:require-password-change command.

<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-02-SUMMARY.md ```go func bouncer.VerifyClaims(tokenString, secret string) (sub string, iat, exp time.Time, jti string, err error) func bouncer.HashPassword(cost int, plain string) (string, error) func bouncer.CheckPassword(hash, plain string) bool func bouncer.NeedsRehash(hash string, configuredCost int) bool func apiArray(ctx context.Context, app *backpack.App, user *models.User) (map[string]any, error) // 07-02 Task 3, same package ``` From `summercms.go/lagoon/attach/file.go` and `thumb.go` (read in full before the avatar task — no HTTP handler analog exists yet, this plan writes the first one): ```go type attach.Owner interface{ MorphName() string } type attach.File struct { ID uint; DiskName, FileName string; FileSize int64; ContentType string; Field, AttachmentID, AttachmentType string; IsPublic *bool; SortOrder int; CreatedAt, UpdatedAt time.Time } func (f *attach.File) Thumb(ctx context.Context, bucket *blob.Bucket, w, h int, mode string) (string, error) func attach.DeleteForOwner(tx *gorm.DB, owner attach.Owner, ownerID string, afterCommit func([]string) error) error func attach.BlobKey(diskName string) string func attach.PartitionDirectory(diskName string) string ``` `models.User` has no `MorphName()` yet — this plan adds `func (User) MorphName() string { return "Golem15\\User\\Models\\User" }` (the PHP class string, per the `Owner` interface contract other Phase 5 models already implement) so `attach.File` rows and their `blobKeysFor` cleanup match cutover-imported rows. Task 1: Reset/activation codes and their four handlers ../fonoteka.go/plugins/golem15/user/classes/codes.go, ../fonoteka.go/plugins/golem15/user/classes/codes_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/config/config.yaml ../fonoteka.go/plugins/golem15/fonoteka/classes/auth/token_guard.go (sha256-hex hashing convention; this task's compare is constant-time string compare, not hash compare — codes are short and never hashed at rest, matching PHP's plain-text `reset_password_code`/`activation_code` columns), ../fonoteka.go/plugins/golem15/user/classes/mail.go (07-02 Task 2 — mailTemplate/resolveMailLocale, the locale-suffix helper every mail send in this plan must route through, per C-05/P4 D-08), ../fonoteka.go/plugins/golem15/user/models/user.go (from 07-02 — ResetPasswordCode/ResetPasswordCodeIssuedAt/ActivationCode/ActivationCodeIssuedAt fields), /media/nvme/dev/golem15/fonoteka/plugins/golem15/user/controllers/ApiController.php lines 180-245, 638-715 (activate/activateByCode/forgotPassword/resetPassword — already read this session), /media/nvme/dev/golem15/fonoteka/vendor/winter/storm/src/Auth/Models/User.php lines 209-292 (activation/reset code semantics, no expiry — already read this session), .planning/phases/07-user-plugin-and-authentication/07-CONTEXT.md (D-15, D-18, D-20) - Test (codes): `IssueResetCode`/`IssueActivationCode` store a non-empty code plus an issued-at timestamp equal to `time.Now()` (within a small tolerance) and return the raw code. - Test (codes): `VerifyResetCode(ctx, db, id, code)` succeeds for the exact issued code within the configured TTL (`reset_ttl_minutes`, default 60), fails for a wrong code, and fails once `issued_at + TTL` has passed. - Test (codes): a row whose `reset_password_code_issued_at` is `NULL` (simulating a PHP-issued, pre-cutover code) is treated as issued "now" and gets one full TTL (D-15's null-issued-at rule) — insert such a row directly via SQL in the test, bypassing the Go issuing path, to prove this. - Test (codes): the compare uses `crypto/subtle.ConstantTimeCompare` (assert via a code-reading check, or a timing-insensitive behavioral test that a code differing only in its last byte fails identically to one differing in its first byte — do not assert on timing directly, that is flaky; assert on the constant-time helper being called via a small refactor seam if easier, e.g. a package-level `compareFunc` variable swapped in the test). - Test (forgot-password handler): both an existing and a nonexistent email return the IDENTICAL `{"message":"If that email exists, a reset link has been sent."}`,200; a malformed email returns 422 `{"error":"...","errors":{"email":[...]}}`. - Test (reset-password handler): a valid `"{id}!{code}"` plus a confirmed new password returns 200 `{"message":"Password has been reset"}`, and the user can then log in with the new password but NOT the old one; a malformed code (no `!`) returns 422 `{"error":"Invalid reset code"}`; a wrong or expired code returns 422 `{"error":"Invalid or expired reset code"}`. - Test (activate handler, authenticated): calling it with a WRONG code still returns 200 `{"user":{...}}` with `is_activated` unchanged (PHP's `attemptActivation()` call has no `if` guard — ApiController.php:180-196, confirmed this session; do not add a check Go-side that PHP doesn't have). - Test (activate-by-code handler, public): a valid `"{id}!{code}"` returns 200 `{"message":"Account activated","token":"...","user":{...}}` with `is_activated=true`; an invalid one returns 422 `{"error":"This activation link is invalid or has expired"}`; a missing `code` field returns 422 `{"error":"...","errors":{"code":[...]}}`. Extend `../fonoteka.go/plugins/golem15/user/config/config.yaml`'s `activation` section (already present from 07-02) is sufficient — do not duplicate `reset_ttl_minutes`/`activation_ttl_hours` under a new key.
Create `classes/codes.go`: `func newCode() (string, error)` — 32 random bytes via `crypto/rand`, hex-encoded (64 chars; PHP's own generation algorithm need not be reproduced bit-for-bit, only the stored plain-text format and the `"{id}!{code}"` link format matter for cutover compatibility, D-15). `func IssueResetCode(ctx, db *gorm.DB, user *models.User) (code string, err error)` and `IssueActivationCode(...)` — generate, `UpdateColumns` the code + its issued-at column to `time.Now()`, return the raw code. `func VerifyResetCode(ctx, db, userID uint, code string) (*models.User, bool)` and `VerifyActivationCode(...)`: load the user by id (do not filter `deleted_at` for activation — a trashed user restoring via a matching activation code is a real PHP path, `attemptActivation`'s trashed branch), compute the TTL cutoff (`issuedAt` = the column's value, or `time.Now()` when the column is `NULL` — D-15's cutover rule — compared against `time.Now()`), reject if past cutoff, `subtle.ConstantTimeCompare([]byte(code), []byte(stored)) == 1` (pad/compare lengths safely — `ConstantTimeCompare` requires equal-length slices; a length mismatch is simply "not equal", checked before calling it, still without a data-dependent branch on the code's actual bytes) . On success for reset: also flip `has_self_set_password=true` (D-15/k7ut351s-equivalent parity, mirrors PHP's `attemptResetPassword`) and clear the code; caller sets the new password. On success for activation: set `is_activated=true`, `activated_at=now`, clear `activation_code`; if the user was soft-deleted, `db.Unscoped().Model(user).Update("deleted_at", nil)` first (the trashed branch).

In `controllers/api_controller.go`, add:
- `ForgotPassword(app)`: validate `email` (`required|email|between:6,255`) → 422 on failure. Look up by email; if found, `IssueResetCode`, build the reset link (`golem15.user.reset_url_base` config, default `<app.url>/reset-password`, `?code=<id>!<code>`), send `mailTemplate("golem15.user::mail.restore", resolveMailLocale(ctx, user))` (vars `name, link, code`) through `postcard.Mailer` resolved via `app.Lookup[postcard.Mailer]()` — log-and-continue on a send error (D-18, never surface it). `mailTemplate`/`resolveMailLocale` are the 07-02 Task 2 helpers (`classes/mail.go`) — reuse them here verbatim, do not hardcode `"golem15.user::mail.restore"` as the final template name. Always return `{"message":"If that email exists, a reset link has been sent."}`,200 regardless of any branch above.
- `ResetPassword(app)`: validate `code` (`required`) and `password` (`required|between:8,255|confirmed`) → 422 on failure with the flattened-errors envelope. Split `code` on `!`; not exactly 2 parts → `{"error":"Invalid reset code"}`,422. `VerifyResetCode`; failure → `{"error":"Invalid or expired reset code"}`,422. Success: hash the new password, save, set `TokensValidAfter = time.Now()` (D-20 — unauthenticated flow, no presenting token to exempt), `{"message":"Password has been reset"}`,200.
- `Activate(app)`: per-handler Bearer-only auth (`bouncer.NewJWTGuard(secret, users, blacklist).Authenticate(r)`, failure → the standard `{"error":true,"message":"Unauthorized"}`,401). Call `VerifyActivationCode(ctx, db, user.ID, r.Form.Get("code"))` and IGNORE its boolean result — always return `{"user": <apiArray>}`,200 (reproducing the missing `if` at ApiController.php:184, confirmed this session — this is a deliberate PHP quirk, not a bug to silently fix).
- `ActivateByCode(app)`: no auth. Validate `code` (`required`) → 422. Split on `!`; not 2 parts → `{"error":"Invalid activation code"}`,422. Load user by id; `VerifyActivationCode` fails or user missing → `{"error":"This activation link is invalid or has expired"}`,422. Success: mint a token (`bouncer.Mint`, `issuerURL` = this endpoint's own URL), `{"message":"Account activated","token":token,"user":<apiArray>}`,200.

Add the four routes to the existing `/_user/api/v1` group in `routes.go`: `g.Post("/forgot-password", controllers.ForgotPassword(p.app))`, `g.Post("/reset-password", controllers.ResetPassword(p.app))`, `g.Post("/activate", controllers.Activate(p.app))`, `g.Post("/activate-by-code", controllers.ActivateByCode(p.app))`.
go vet ./... && go test ./plugins/golem15/user/... -run 'TestCodes|TestForgotPassword|TestResetPassword|TestActivate' -short - `ForgotPassword` returns the byte-identical 200 body for both an existing and a nonexistent email - `ResetPassword` with a malformed code (no `!`) returns 422 `{"error":"Invalid reset code"}`; a wrong/expired code returns 422 `{"error":"Invalid or expired reset code"}`; success returns 200 `{"message":"Password has been reset"}` and the user can log in with the new password - `Activate` (authenticated) returns 200 `{"user":{...}}` even when the supplied code is wrong, with `is_activated` unchanged — no `if` guard on the boolean result - `ActivateByCode` on a valid `"{id}!{code}"` returns 200 with a fresh token and `is_activated:true` - A row with `reset_password_code_issued_at = NULL` inserted directly via SQL is treated as issued now and accepts its code within one full TTL - `ForgotPassword`'s `mail.restore` template-name computation calls `mailTemplate`/`resolveMailLocale`, not a hardcoded string literal - `go test ./plugins/golem15/user/... -run 'TestCodes|TestForgotPassword|TestResetPassword|TestActivate'` exits 0 All four handlers reproduce their PHP status/body pairs including the asymmetric authenticated-activate no-op-on-failure behavior; codes compare in constant time and honor the TTL and null-issued-at cutover rule. Task 2: Profile update, change-password with D-20 exemption, marketing consent ../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/controllers/api_controller.go (Login/Logout from 07-02 — the per-handler-auth idiom to copy verbatim for Update/ChangePassword/MarketingConsent), summercms.go/bouncer/jwt.go and mint.go (VerifyClaims, Mint — for the D-20 cutover math), /media/nvme/dev/golem15/fonoteka/plugins/golem15/user/controllers/ApiController.php lines 364-515, 595-626 (update/changePassword/updateMarketingConsent — already read this session), .planning/phases/07-user-plugin-and-authentication/07-CONTEXT.md (D-20, its resolved Open Question 1) - Test (update): valid `name/surname/email` returns 200 `{"message":"Profile updated","user":{...}}` with the new values reflected; re-saving the CALLER's own unchanged email does not trip the uniqueness check; a duplicate OTHER user's email returns 422; missing `name` returns 422 with `{"error":"...","errors":{"name":[...]}}`. - Test (change-password, self-set path): correct `current_password` + a `password`/`password_confirmation` pair different from the current one returns 200 `{"message":"Password changed","user":{...}}`; the OLD token used to make this exact call still authenticates a subsequent `fetch` (D-20 exemption); a DIFFERENT older token for the same user (minted before this call) now gets 401 on `fetch`. - Test (change-password): wrong `current_password` returns 422 `{"error":"Invalid email or password","errors":{"current_password":["Invalid email or password"]}}` (reusing the login-failure message text per ApiController.php:451, confirmed this session). - Test (change-password): `password` equal to `current_password` (the `different:current_password` rule) returns 422. - Test (change-password, `has_self_set_password=false` row): the handler 500s with an opaque body rather than silently treating the row as self-set (D-03's "documented as deferred, not silently treated as self-set" — Open Question 3's confirmed low-risk resolution: no Go-created row can reach this state today, so a 500 fail-loud guard is correct and should never fire in practice). - Test (marketing-consent): `{"marketing_consent": true}` returns 200 `{"message":"Marketing consent updated","user":{...}}` with `marketing_consent: true`; `false` clears it; a non-boolean value returns 422. Add `Update(app)`: per-handler Bearer-only auth. Validate `name` (`required|between:2,255`), `surname` (`required|between:2,255`), `email` (`required|between:6,255|email|unique:users` — `lagoon.Validate`'s `uniqueOK` already excludes the caller's own row via `modelUintID`, matching PHP's `unique:users,email,{id}`). On failure, `{"error": , "errors": {...}}`,422. On success, set the three fields directly (never mass-assign the whole request body — matches PHP's explicit-field-write comment) and save; `{"message":"Profile updated","user": }`,200.
Add `ChangePassword(app)`: per-handler auth via `bouncer.VerifyClaims` (not the plain guard — this handler needs the presenting token's `iat` for the D-20 exemption). On auth failure, `{"error":true,"message":"Unauthorized"}`,401. Compute `hasSelfSetPassword := user.HasSelfSetPassword` — if `false`, return an opaque `500` (`wire.WriteOpaque500`), log server-side, per D-03/Open Question 3 (this branch is provably unreachable for any Go-created row but must fail loud, not silently succeed, if a cutover-migrated row ever hits it). Validate: `current_password` `required`, `password` `required|between:<min_password_length>,255|confirmed|different:current_password` (config `golem15.user.password.min_length`, default 8). On validation failure, `{"error": <first>, "errors": {...}}`,422. Check `bouncer.CheckPassword(user.Password, currentPassword)`; on mismatch, `{"error":"Invalid email or password","errors":{"current_password":["Invalid email or password"]}}`,422 (reuses the login-failure text verbatim, per source). On success: hash the new password, `MustChangePassword=false`, `HasSelfSetPassword=true` (already true on this path, set anyway to mirror PHP), save. THEN apply D-20: `cutoff := iat.Add(-1 * time.Second)` (the presenting token's own `iat`, minus one second, so `iat.Before(cutoff)` is false for THIS token and true for any older one — the Open Question 1 exemption), `user.TokensValidAfter = cutoff`, save that column too. Return `{"message":"Password changed","user": <apiArray>}`,200 — NOT a fresh token (confirmed PHP never returns one from this endpoint).

Add `MarketingConsent(app)`: per-handler Bearer-only auth. Validate `marketing_consent` (`required|boolean`) → 422 on failure. Set the column, save, reload; `{"message":"Marketing consent updated","user": <apiArray>}`,200.

Add the three routes to the `/_user/api/v1` group: `g.Post("/update", controllers.Update(p.app))`, `g.Post("/change-password", controllers.ChangePassword(p.app))`, `g.Post("/marketing-consent", controllers.MarketingConsent(p.app))`.
go vet ./... && go test ./plugins/golem15/user/... -run 'TestUpdate|TestChangePassword|TestMarketingConsent' -short - `Update` with a duplicate OTHER user's email returns 422; re-saving the caller's own unchanged email does not - `ChangePassword` with the correct current password and a new, different, confirmed password returns 200 `{"message":"Password changed","user":{...}}` with no `token` key - The presenting token used to CALL `ChangePassword` still authenticates a subsequent `fetch`; a token minted for the same user BEFORE that call gets 401 on `fetch` afterward - Wrong `current_password` returns 422 `{"error":"Invalid email or password","errors":{"current_password":["Invalid email or password"]}}` - `MarketingConsent` with `{"marketing_consent":true}` returns 200 with `user.marketing_consent == true` change-password's D-20 exemption is proven by a passing test (presenting token survives, a prior token does not); update/marketing-consent match their PHP bodies exactly. Task 3: Avatar upload/remove, mail templates, require-password-change command ../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/models/user.go, ../fonoteka.go/plugins/golem15/user/console/require_password_change.go, ../fonoteka.go/plugins/golem15/user/console/require_password_change_test.go, ../fonoteka.go/plugins/golem15/user/views/mail/activate.htm, ../fonoteka.go/plugins/golem15/user/views/mail/activate-en.htm, ../fonoteka.go/plugins/golem15/user/views/mail/restore.htm, ../fonoteka.go/plugins/golem15/user/views/mail/restore-en.htm, ../fonoteka.go/plugins/golem15/user/views/mail/reactivate.htm, ../fonoteka.go/plugins/golem15/user/views/mail/reactivate-en.htm, ../fonoteka.go/plugins/golem15/user/views/mail/layouts/user.htm, ../fonoteka.go/plugins/golem15/user/plugin.go summercms.go/lagoon/attach/file.go, summercms.go/lagoon/attach/thumb.go, summercms.go/lagoon/attach/bucket.go (Owner, File, Thumb, OpenBucket — no HTTP handler analog exists, this is net new per PATTERNS.md), summercms.go/examples/hello/plugins/base/plugin.go and views/mail/{hello.htm,hello-en.htm} and views/mail/layouts/hello.htm (the exact template file format: INI header, `==` separators, markdown body — read the actual .htm files, not just the plugin.go, before authoring new ones), summercms.go/postcard/mailer.go, postcard/templates.go (Catalog.Register, Message, Mailer.Send), summercms.go/bonfire/command.go (Command, Arg, Input.Argument), summercms.go/lagoon/commands.go (a command taking a parameter, for the Run-closure shape), /media/nvme/dev/golem15/fonoteka/plugins/golem15/user/controllers/ApiController.php lines 517-584 (updateAvatar/removeAvatar — already read this session), .planning/phases/07-user-plugin-and-authentication/07-CONTEXT.md (D-04, D-21, C-05) - Test (avatar upload): a multipart POST with a valid small JPEG under `avatar` returns 200 `{"message":"Avatar updated","user":{...,"has_avatar":true,"avatar_url":"<128 thumb URL>"}}`; a `.svg` (or any non-`jpeg,jpg,png,webp,gif` sniffed content type) returns 422 `{"error":"...","errors":{"avatar":[...]}}`; an oversized file (>4000 KB) returns 422 with an avatar-size error; a request with no `avatar` field returns 422. - Test (avatar remove): removing an existing avatar returns 200 `{"message":"Avatar removed","user":{...,"has_avatar":false}}` and the underlying `system_files` row plus its blob are gone; removing when none exists returns 422 `{"error":"Your account has no display picture to remove.","errors":{"avatar":["Your account has no display picture to remove."]}}`. - Test (mail): `postcard`'s `memory` driver captures a `mail.activate` send with `Vars["link"]` containing the generated activation code, when `Register`'s `user`-activation-mode branch runs (wire this test through the shared `postcard.Mailer` the plugin now publishes/resolves, matching the Phase 4 `memory`-driver assertion pattern). - Test (mail locale suffix, BLOCKER fix): register two users, one with `PreferredLocale="pl"` and one with `PreferredLocale="en"`, and trigger `ForgotPassword` for each. The `memory` driver's captured `Message.Template` is exactly `"golem15.user::mail.restore"` for the `pl` user and exactly `"golem15.user::mail.restore-en"` for the `en` user -- proving `mailTemplate`/`resolveMailLocale` actually drive the real send, not just the pure-function unit test from 07-02. Repeat the same assertion shape for `mail.activate` (Register, user-mode) and `mail.reactivate` (Login, soft-delete restore) so all three call sites are covered by an end-to-end locale-suffix test, not just one. - Test (console command): `user:require-password-change alice@example.com` sets `must_change_password=true` for that user and prints a success line; an unknown email returns a command error, not a panic; the command is discoverable via `pact.HasCommands`. Add `func (User) MorphName() string { return "Golem15\\User\\Models\\User" }` to `models/user.go` (the `attach.Owner` interface, PHP class string verbatim so cutover-imported `system_files.attachment_type` rows keep matching, mirroring how Phase 5 models already implement this).
In `api_controller.go`, add `UploadAvatar(app)`: per-handler Bearer-only auth. `r.ParseMultipartForm(10 << 20)`; `file, header, err := r.FormFile("avatar")`; missing/parse-error → `{"error":"...","errors":{"avatar":["The avatar field is required."]}}`,422. Read the first 512 bytes for `http.DetectContentType` (do not trust the client-supplied filename extension or `Content-Type` header), map the sniffed MIME to an extension in `{jpeg,jpg,png,webp,gif}` (jpeg/jpg both map from `image/jpeg`); a type outside that set → 422 `{"error":"...","errors":{"avatar":["The avatar must be a file of type: jpeg, jpg, png, webp, gif."]}}`. `header.Size > 4_096_000` (4000 KB) → 422 `{"error":"...","errors":{"avatar":["The avatar must not be greater than 4000 kilobytes."]}}`. On success: remove any existing avatar first (reuse the remove logic below, ignoring "none exists"), write the new file's bytes to the storage bucket at `attach.BlobKey(diskName)` (a generated disk name, e.g. hex-random + detected extension), insert an `attach.File{DiskName: ..., FileName: header.Filename, FileSize: header.Size, ContentType: detectedMIME, Field: "avatar", AttachmentID: strconv.FormatUint(uint64(user.ID),10), AttachmentType: user.MorphName(), SortOrder: 0}` row, call `f.Thumb(ctx, bucket, 128, 128, "auto")` for the `avatar_url` payload value. `{"message":"Avatar updated","user": <apiArray with has_avatar:true, avatar/avatar_url populated>}`,200. Resolve the blob bucket via `app.Lookup[*blob.Bucket]()` (published by Phase 5's `attach.Publish`, already wired at app boot).

Add `RemoveAvatar(app)`: per-handler auth. Look up the user's `attach.File` row (`WHERE attachment_type = ? AND attachment_id = ? AND field = 'avatar'`); none found → `{"error":"Your account has no display picture to remove.","errors":{"avatar":["Your account has no display picture to remove."]}}`,422. Found: `attach.DeleteForOwner(tx, user, id, afterCommit)` inside a transaction, then in `afterCommit` call `attach.DeleteKeys(ctx, bucket, keys)` after the transaction commits (two-phase contract, per `file.go`'s documented contract — never delete blobs before commit). `{"message":"Avatar removed","user": <apiArray with has_avatar:false>}`,200.

Add the two routes: `g.Post("/avatar", controllers.UploadAvatar(p.app))`, `g.Post("/avatar/remove", controllers.RemoveAvatar(p.app))`.

Author the three mail templates plus layout, copying the exact `.htm` file format from `examples/hello/plugins/base/views/mail/{hello,hello-en}.htm` and `views/mail/layouts/hello.htm` (INI header with `subject`/`layout` keys, `==` separator, markdown body with `{{.Name}}`/`{{.Link}}`/`{{.Code}}` placeholders — `postcard`'s `execHTML`/`execText` use Go's `html/template`/`text/template` syntax, not PHP's `{name}`). `activate`/`activate-en`: subject "Activate your account" / "Activate your account", body links to the activation URL. `restore`/`restore-en`: subject "Reset your password", body links to the reset URL. `reactivate`/`reactivate-en`: subject "Welcome back", informs the user their account was reactivated on login (D-17). `layouts/user.htm`: a plain wrapper mirroring `layouts/hello.htm`'s three-section format exactly. Mail content is NOT parity-critical (D-18 — asserted only through the `memory` driver in tests, never byte-diffed against PHP), so reasonable English copy is sufficient; Polish (`pl`) is the default locale content, `-en` is the English sibling per C-05's caller-picks-suffix convention.

Extend `plugin.go`: add `pact.HasMailTemplates` conformance (`MailTemplatesFS() fs.FS` via `//go:embed views/mail`, `MailTemplates() []string` listing all six dotted names, `MailLayouts() map[string]string{"user": "golem15.user::mail.layouts.user"}`), matching `examples/hello/plugins/base/plugin.go`'s shape exactly. Wire `Register(app)` or `Boot(app)` to call `postcard.Activate`-published catalog's `Register` (check whether `postcard.Activate` already runs at the kernel level before plugin `Boot` per its doc comment — if so, this plugin only needs to declare the capability interfaces, not call `Register` itself; confirm against `postcard/mailer.go`'s `Activate` doc comment and an existing plugin that already ships mail templates, e.g. `golem15.hello`, before writing any manual `Register` call).

Create `console/require_password_change.go`: a `pact.HasCommands` implementation (or extend `plugin.go`'s existing `Commands()` if a scaffolded stub already exists — none does yet, this is genuinely new) registering `bonfire.Command{Name: "user:require-password-change", Description: "Force a user to change their password on next login (D-21)", Args: []bonfire.Arg{{Name: "email", Required: true}}, Run: func(ctx, in, out) error { ... }}` per the `migrate:rollback`/positional-arg shape in `bonfire/command.go` and `lagoon/commands.go`. `Run` looks up the user by email, 404-equivalent command error if not found, sets `must_change_password=true`, `out.Success(...)`. Wire `func (p *Plugin) Commands() []bonfire.Command` on the plugin and declare `var _ pact.HasCommands = (*Plugin)(nil)`.
go vet ./... && go test ./plugins/golem15/user/... -run 'TestUploadAvatar|TestRemoveAvatar|TestMail|TestRequirePasswordChange' -short - A valid small JPEG upload returns 200 with `user.has_avatar == true` and a non-empty `avatar_url` - A `.svg` (or any content type outside jpeg/jpg/png/webp/gif by sniffed MIME) returns 422 with an `errors.avatar` key - `RemoveAvatar` with no existing avatar returns 422 `{"error":"Your account has no display picture to remove.","errors":{"avatar":["Your account has no display picture to remove."]}}` - The `postcard` memory driver records a `mail.activate` send with a non-empty `link`/`code` var when register's user-mode branch runs - A `pl`-locale user's forgot-password send captures `Message.Template == "golem15.user::mail.restore"`; an `en`-locale user's captures `"golem15.user::mail.restore-en"` -- same pl/en pair proven for mail.activate and mail.reactivate - `user:require-password-change alice@example.com` sets `must_change_password=true` for that row and is listed by `Commands()` Avatar upload/remove round-trip through Phase 5's attach primitives with the exact D-04 payload shape; all three mail templates render and send through the memory driver in tests, with pl/en locale-suffix selection proven end to end for all three call sites; the console command sets must_change_password and is discoverable via Commands().

<threat_model>

Trust Boundaries

Boundary Description
Client → reset/activation codes Untrusted "{id}!{code}" strings cross into a database lookup and a password/activation state change
Client → avatar upload Untrusted multipart file bytes cross into storage and are later served back as a public URL

STRIDE Threat Register

Threat ID Category Component Disposition Mitigation Plan
T-07-03 Information Disclosure ForgotPassword mitigate Identical 200 body regardless of account existence (D-18), matching the already-shipped PHP behavior — never regress to a distinguishable response
T-07-05 Spoofing codes.go VerifyResetCode/VerifyActivationCode mitigate crypto/subtle.ConstantTimeCompare (D-15 hardening over PHP's plain ===) plus a new TTL (reset 60 min / activation 72 h)
T-07-09 Denial of Service UploadAvatar mitigate mimes sniffed-content-type check (not client-supplied extension) + explicit 4000 KB size check on top of the existing automatic 128 MiB defaultBytes transport cap (P6 D-18)
T-07-13 Elevation of Privilege ChangePassword's has_self_set_password=false branch mitigate Fail-loud opaque 500 instead of silently treating the row as self-set (D-03/Open Question 3) — provably unreachable for any Go-created row today, so this is defence-in-depth against a future cutover-migrated row

</threat_model>

`go vet ./...` and `go test ./... -short` green in `fonoteka.go`. Full manual sequence: register (user-mode config override) → forgot-password → reset-password with the seeded code → login with the new password → change-password → old token now 401s on fetch, the change-password call's own token does not.

<success_criteria> Every /_user/api/v1 route named in D-01 now has a real handler; golem15.user's own plugin code is feature-complete for AUTH-01. </success_criteria>

Create `.planning/phases/07-user-plugin-and-authentication/07-03-SUMMARY.md` when done