docs(07): revise plans after checker review
This commit is contained in:
@@ -37,6 +37,7 @@ must_haves:
|
||||
- "bouncer.Principal carries PreferredLocale and TokensValidAfter so I18N-02 and D-20 have a place to attach without a second DB round-trip"
|
||||
- "surf's post-auth locale stage overrides towel locale from Principal.PreferredLocale only when non-empty, and only after an auth guard has resolved a Principal (C-01, I18N-02)"
|
||||
- "lagoon.Validate accepts email, confirmed, different:field and mimes:list tokens the user plugin's register/update/change-password/avatar rules need (Pitfall 6)"
|
||||
- "bouncer.HashPassword defaults to bcrypt cost 10 (config-driven), a real PHP $2y$ hash verifies unchanged via CheckPassword, and NeedsRehash flags a hash whose cost is lower than configured for silent rehash on login, per D-19"
|
||||
artifacts:
|
||||
- path: "bouncer/mint.go"
|
||||
provides: "Mint(secret, sub, issuerURL string, ttl time.Duration) (token, jti string, err error) with the hardcoded prv constant"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -30,7 +30,10 @@ must_haves:
|
||||
- "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 (C-05)"
|
||||
- "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"
|
||||
artifacts:
|
||||
- path: "../fonoteka.go/plugins/golem15/user/classes/codes.go"
|
||||
provides: "IssueResetCode/IssueActivationCode/VerifyResetCode/VerifyActivationCode with constant-time compare and TTL"
|
||||
@@ -106,6 +109,7 @@ func attach.PartitionDirectory(diskName string) string
|
||||
</files>
|
||||
<read_first>
|
||||
../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),
|
||||
@@ -127,7 +131,7 @@ func attach.PartitionDirectory(diskName string) string
|
||||
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 `mail.restore` (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). Always return `{"message":"If that email exists, a reset link has been sent."}`,200 regardless of any branch above.
|
||||
- `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.
|
||||
@@ -143,6 +147,7 @@ func attach.PartitionDirectory(diskName string) string
|
||||
- `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
|
||||
</acceptance_criteria>
|
||||
<done>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.</done>
|
||||
@@ -222,6 +227,7 @@ func attach.PartitionDirectory(diskName string) string
|
||||
- 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`.
|
||||
</behavior>
|
||||
<action>
|
||||
@@ -247,9 +253,10 @@ func attach.PartitionDirectory(diskName string) string
|
||||
- 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()`
|
||||
</acceptance_criteria>
|
||||
<done>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; the console command sets must_change_password and is discoverable via Commands().</done>
|
||||
<done>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().</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
@@ -36,6 +36,7 @@ must_haves:
|
||||
- "The recorded corpus includes the A2 fixture (6 rapid failed logins) settling whether Winter's throttle actually gates the JWT login path, and the D-12 code-carrying two-step flows (forgot->reset, register->activate-by-code) using a real code read from the target's own database, not a hardcoded value"
|
||||
- "No live JWT, inv_ token or database credential is committed to git; the private vars store stays mode 0600 and untracked"
|
||||
- "A new nuxt-auth client flow fixture exercises register -> fetch -> update -> change-password -> refresh -> logout -> refused-reuse, plus a must_change_password user reaching 423 then clearing it via change-password after a successful me/locale call (D-14)"
|
||||
- "The 15 new /_user/api/v1 routes are added to fonoteka.go/parity/manifest.yaml (absent from the original 154-route fonoteka manifest), the fixed-154-total wording in parity_test.go/check_corpus.go is corrected to the new total, and no fixture is recorded for a dropped extraction source (query-string/body jwt_token, per D-09), per D-11"
|
||||
artifacts:
|
||||
- path: "../fonoteka.go/parity/fixtures/nuxt/nuxt-auth.yaml"
|
||||
provides: "the D-14 recorded client flow"
|
||||
@@ -126,7 +127,11 @@ func (s *Store) Expand(text string) (string, error) // {{name}} substitution us
|
||||
|
||||
Create `db_capture.go` (package `main`, alongside the existing `parity_test.go` — or a small standalone `go run`-able file, whichever fits the existing package layout better once read): a helper that, given a target's connection info (the isolated PHP's SQLite path from `$PARITY_ROOT`, or the Go replay target's Postgres DSN) and an email, reads `reset_password_code` or `activation_code` from the `users` row and calls `tide.OpenStore(varsPath)` → `Set("code:reset", value)` or `Set("code:activate", value)` → `Save()`. For the PHP/SQLite side, shell out to `../fonoteka.go/parity/php_parity.sh artisan tinker --execute="echo \Golem15\User\Models\User::where('email','<email>')->value('reset_password_code');"` (avoids adding a new Go SQLite driver dependency for parity-only tooling) and capture stdout. For the Go/Postgres replay side, use the existing `*sql.DB`/DSN the replay harness already opens — a direct `SELECT reset_password_code FROM users WHERE email = $1`. This is the D-12 "app-owned tide seed/capture hook" — it lives in `fonoteka.go/parity`, not in the framework-owned `tide` package, exactly because it knows the Płytarium `users` table shape.
|
||||
|
||||
Record, for EACH of the 15 routes (`login, logout, fetch, refresh, register, forgot-password, reset-password, activate, activate-by-code, update, change-password, avatar, avatar/remove, marketing-consent, oauth-providers`), every distinct status+body PHP actually returns (D-13 — do not assume the bodies drafted during planning are exact; RECORD the real ones and treat any drift as authoritative over the plan text): success case, validation-failure (422) case where applicable, and the specific failure modes named in 07-CONTEXT.md (bad credentials, suspended/banned via the throttle, registration disabled/throttled, bad/expired refresh, wrong current password, expired/invalid reset or activation code). Use `summer parity:record --spec=<one-off YAML per case> --target=http://127.0.0.1:8423 --rules=capture-rules.yaml --vars=<private path> --manifest=manifest.yaml --fixtures=fixtures/routes --next-batch=15 --resume=true` for the bulk of the batch (the whole 15-route surface fits in one `--next-batch=15` call, matching the established 15-route resume workflow), then hand-author additional YAML specs for the extra per-route failure cases and record those individually with `--spec`.
|
||||
Record, for EACH of the 15 routes (`login, logout, fetch, refresh, register, forgot-password, reset-password, activate, activate-by-code, update, change-password, avatar, avatar/remove, marketing-consent, oauth-providers`), every distinct status+body PHP actually returns (D-13 — do not assume the bodies drafted during planning are exact; RECORD the real ones and treat any drift as authoritative over the plan text): success case, validation-failure (422) case where applicable, and the specific failure modes named in 07-CONTEXT.md (bad credentials, suspended/banned via the throttle, registration disabled/throttled, bad/expired refresh, wrong current password, expired/invalid reset or activation code). Every recorded `user` payload (login/fetch/register/update success bodies) MUST include the literal `feedback_widget_hidden: false` key -- if a recorded fixture is missing it, that is a signal the base payload construction in 07-02 needs a follow-up, not that the fixture should be trimmed to match.
|
||||
|
||||
Give two DELIBERATE PHP-quirk reproductions their own named recording pass, since they are easy to silently "fix" during recording if the operator assumes the plan text is wrong instead of PHP: (1) `register()` with `allow_registration=false` or the register throttle tripped, under the pinned `APP_DEBUG=false` -- confirm the recorded body is `{"error":"Internal server error"}`,500, NOT the literal "Registrations are currently disabled."/"Registration is throttled..." text (07-02 Task 3's `SafeExceptionResponse` finding); (2) `POST activate` (authenticated) with a WRONG code -- confirm the recorded body is still 200 `{"user":{...}}` with `is_activated` unchanged, NOT a 422/401 (07-03 Task 1's missing-`if`-guard finding). For both, the RECORDED PHP body is authoritative: if either one comes back looking different from what 07-02/07-03 assumed, that is a real finding to write into this plan's SUMMARY and file as a gap-closure item against the plan that implemented it -- do not silently adjust the fixture to match the plan text's expectation.
|
||||
|
||||
Use `summer parity:record --spec=<one-off YAML per case> --target=http://127.0.0.1:8423 --rules=capture-rules.yaml --vars=<private path> --manifest=manifest.yaml --fixtures=fixtures/routes --next-batch=15 --resume=true` for the bulk of the batch (the whole 15-route surface fits in one `--next-batch=15` call, matching the established 15-route resume workflow), then hand-author additional YAML specs for the extra per-route failure cases and record those individually with `--spec`.
|
||||
|
||||
Record the A2 confirmation case explicitly: 6 rapid `POST /_user/api/v1/login` calls with a wrong password for the SAME seeded account, asserting whether the 6th attempt's body differs from attempts 1-5 (settling Assumption A2 — if PHP's `JWTAuth::attempt()` does NOT actually reach Winter's throttle for this route, the 6th attempt's body will be identical to the first; if it does, confirm whatever the actual PHP body is and make sure the Go implementation from 07-02 matches it, filing a gap-closure note in this plan's SUMMARY if a code change is needed).
|
||||
|
||||
@@ -147,8 +152,10 @@ func (s *Store) Expand(text string) (string, error) // {{name}} substitution us
|
||||
- The A2 fixture (6 rapid failed logins) exists and its 6th-attempt body is explicitly compared against attempt 1 in this plan's SUMMARY
|
||||
- `fixtures/nuxt/nuxt-auth.yaml` exists and its steps cover register, fetch, update, change-password, refresh, logout, and a refused token-reuse step
|
||||
- `parity_test.go`'s `expectedPHPRoutes` reads `169`
|
||||
- The recorded `register` disabled/throttled fixture body is `{"error":"Internal server error"}`,500 under `APP_DEBUG=false`, and the recorded authenticated `activate`-with-wrong-code fixture body is 200 `{"user":{...}}` with `is_activated` unchanged -- both named explicitly in this plan's SUMMARY as confirmed, not assumed
|
||||
- Every recorded login/fetch/register/update success fixture's `user` payload contains the literal key `feedback_widget_hidden: false`
|
||||
</acceptance_criteria>
|
||||
<done>15 new manifest entries exist with D-13-complete case coverage; the A2 and D-12 cases are recorded and settled; nuxt-auth.yaml exists and records the full D-14 sequence.</done>
|
||||
<done>15 new manifest entries exist with D-13-complete case coverage; the A2 and D-12 cases are recorded and settled; nuxt-auth.yaml exists and records the full D-14 sequence; both named PHP-quirk reproductions (register disabled/throttled degrading to 500, authenticated activate's no-op-on-wrong-code) are confirmed against the real recorded PHP body.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:human-verify" gate="blocking">
|
||||
@@ -156,7 +163,7 @@ func (s *Store) Expand(text string) (string, error) // {{name}} substitution us
|
||||
<files>../fonoteka.go/parity/fixtures/routes, ../fonoteka.go/parity/fixtures/nuxt/nuxt-auth.yaml, ../fonoteka.go/parity/manifest.yaml</files>
|
||||
<read_first>the fixture files Task 2 recorded, ../fonoteka.go/parity/manifest.yaml</read_first>
|
||||
<action>
|
||||
Confirm via `git status` over `fonoteka.go/parity/` that the private vars store path is NOT staged (it should sit outside the repo or be gitignored, mode 0600, per the established Phase 2 convention). Grep the new fixtures for live JWT/`inv_` token shapes (a capture leak `tide`'s masking should already prevent, verified directly here since these are brand-new files). Run the corpus and replay commands below. Spot-check 2-3 fixture bodies against this plan's D-13 expectations (e.g. the login-failure body, the change-password wrong-current-password body) to confirm the recorded PHP behavior matches what 07-02/07-03 implemented -- flag any drift for a gap-closure note rather than silently accepting a mismatch.
|
||||
Confirm via `git status` over `fonoteka.go/parity/` that the private vars store path is NOT staged (it should sit outside the repo or be gitignored, mode 0600, per the established Phase 2 convention). Grep the new fixtures for live JWT/`inv_` token shapes (a capture leak `tide`'s masking should already prevent, verified directly here since these are brand-new files). Run the corpus and replay commands below. Spot-check 2-3 fixture bodies against this plan's D-13 expectations (e.g. the login-failure body, the change-password wrong-current-password body) to confirm the recorded PHP behavior matches what 07-02/07-03 implemented -- flag any drift for a gap-closure note rather than silently accepting a mismatch. Specifically re-confirm Task 2's two named PHP-quirk recordings here as part of the sign-off: the register disabled/throttled fixture reads `{"error":"Internal server error"}`,500 (not the literal disabled/throttled text), and the authenticated activate-with-wrong-code fixture reads 200 `{"user":{...}}` (not a 4xx) -- both are deliberate reproductions of PHP quirks read directly from source in 07-02/07-03, not planning guesses, so the recorded body is authoritative if either differs from what is written here.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>! grep -rE "eyJ[A-Za-z0-9_-]+[.][A-Za-z0-9_-]+[.][A-Za-z0-9_-]+|inv_[A-Za-z0-9]{8,}" ../fonoteka.go/parity/fixtures/routes/*user_api_v1* ../fonoteka.go/parity/fixtures/nuxt/nuxt-auth.yaml && go test ./parity/... -run TestParityCorpus -v</automated>
|
||||
@@ -169,8 +176,9 @@ func (s *Store) Expand(text string) (string, error) // {{name}} substitution us
|
||||
- The JWT/`inv_`-shape grep over the new fixtures returns zero matches
|
||||
- `git status` does not list the private vars store path
|
||||
- `go test ./parity/... -run TestParityCorpus -v` reports zero failing cases among the newly ported routes
|
||||
- The two named PHP-quirk fixtures (register disabled/throttled -> 500 opaque body; authenticated activate-with-wrong-code -> 200 unchanged) are explicitly re-confirmed in this task's sign-off, with any drift noted as a gap-closure item rather than silently accepted
|
||||
</acceptance_criteria>
|
||||
<done>No live JWT/inv_ token shape is present in any committed fixture; the private vars store is untracked and 0600; TestParityCorpus and summer parity:replay are green for every newly ported route.</done>
|
||||
<done>No live JWT/inv_ token shape is present in any committed fixture; the private vars store is untracked and 0600; TestParityCorpus and summer parity:replay are green for every newly ported route; both named PHP-quirk reproductions are confirmed against the real recorded PHP body.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
@@ -117,11 +117,13 @@ Output: green `go vet`/`go test -race` in both modules, and a `07-VALIDATION.md`
|
||||
- A full sequence test (real Postgres): register → fetch → update → change-password → refresh → logout → the logged-out token gets 401 on a subsequent fetch — the Go-side mirror of the `nuxt-auth` fixture, run as a fast in-process `httptest` sequence independent of the PHP-recorded parity fixtures (this test must be able to run in CI without the isolated PHP instance).
|
||||
- `TestMustChangePasswordLock`: a user with `must_change_password=true` gets 423 on `/_fonoteka/api/v1/genres` and `/_fonoteka/api/v1/tokens`, succeeds on `GET/PUT /_fonoteka/api/v1/me/locale`, succeeds on `/_user/api/v1/change-password`, and after that call the lock is cleared and `/_fonoteka/api/v1/genres` succeeds.
|
||||
- `TestGetApiArray`: the full payload shape (base fields + organisation_id/role + must_change_password + preferred_locale) for a user with all of those set, run against a real boot of both plugins together (not a unit-level mock) — the definitive AUTH-02 acceptance test.
|
||||
- Mail: `postcard`'s `memory` driver captures `mail.activate` (from `Register`'s user-mode branch), `mail.restore` (from `ForgotPassword`), and `mail.reactivate` (from `Login`'s soft-delete-restore branch, D-17) each with the expected `Vars` keys (`link`/`code`/`name` as applicable).
|
||||
- Mail: `postcard`'s `memory` driver captures `mail.activate` (from `Register`'s user-mode branch), `mail.restore` (from `ForgotPassword`), and `mail.reactivate` (from `Login`'s soft-delete-restore branch, D-17) each with the expected `Vars` keys (`link`/`code`/`name` as applicable), AND re-confirms the pl/en locale-suffix routing 07-03 Task 3 already tests (`mailTemplate`/`resolveMailLocale`) still holds after any coverage-driven changes in this task -- do not let a coverage fix silently regress the locale-suffix wiring.
|
||||
- `TestTokenApi`: mint with each of the three individual scopes plus a two-scope combination, then a scope-ceiling violation attempt (`"admin"`) is rejected before any row is persisted; `InvScope` middleware (Phase 6, unchanged) 403s a `write`-scoped route call made with a `read`-only token, proving the ceiling is enforced end-to-end through the ALREADY-SHIPPED Phase 6 gate, not just at mint time.
|
||||
- `TestBlacklistSweepStarts`: booting the `golem15.user` plugin with a short test-only `golem15.user.jwt.blacklist_sweep_interval` and a pre-inserted expired `jwt_blacklist` row observes the row removed after waiting past the interval -- the test hook confirming 07-02 Task 2's sweep goroutine actually runs during a real plugin Boot, not just that `BlacklistStore.Sweep` works in isolation (07-01).
|
||||
- `TestNoDeferredRoutesResolve` (D-05): booting both plugins and asserting the real route table has no entry whose path matches any of the deferred PIN-login, device-auth, 2FA, `GET /api/user/batch`, or `GET /_user/activate/{id}` paths -- and that `POST /_user/api/v1/login`'s success body never contains a `two_factor_required` key.
|
||||
</behavior>
|
||||
<action>
|
||||
Run `go test ./plugins/golem15/... -cover -short` and `-race` in `fonoteka.go`, fill every coverage gap the report shows for Phase 7 files, and add the four cross-cutting tests described above. Where a gap traces to a real bug (not just missing coverage), fix it minimally and record the deviation. Confirm `go list -deps ./plugins/golem15/user/...` contains no `plugins/golem15/fonoteka` import (the AUTH-02 import-direction invariant) as an explicit, named test — not just an incidental compile-time fact.
|
||||
Run `go test ./plugins/golem15/... -cover -short` and `-race` in `fonoteka.go`, fill every coverage gap the report shows for Phase 7 files, and add the six cross-cutting tests described above. Where a gap traces to a real bug (not just missing coverage), fix it minimally and record the deviation. Confirm `go list -deps ./plugins/golem15/user/...` contains no `plugins/golem15/fonoteka` import (the AUTH-02 import-direction invariant) as an explicit, named test — not just an incidental compile-time fact.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test ./... -race -cover</automated>
|
||||
@@ -130,9 +132,11 @@ Output: green `go vet`/`go test -race` in both modules, and a `07-VALIDATION.md`
|
||||
- `go test ./... -race` exits 0 in fonoteka.go
|
||||
- `TestMustChangePasswordLock` proves 423 on `/_fonoteka/api/v1/genres` and `/tokens`, 200 on `me/locale` and `/_user/api/v1/change-password`, and 200 on `/_fonoteka/api/v1/genres` again after the lock clears
|
||||
- `TestGetApiArray` asserts the full payload shape against a real dual-plugin boot, not a mock
|
||||
- The `postcard` memory driver captures all three mail sends (activate/restore/reactivate) with non-empty vars
|
||||
- The `postcard` memory driver captures all three mail sends (activate/restore/reactivate) with non-empty vars, and the pl/en locale-suffix template names for all three
|
||||
- `TestBlacklistSweepStarts` observes an expired `jwt_blacklist` row removed after a real plugin Boot with a short sweep interval
|
||||
- `TestNoDeferredRoutesResolve` finds zero route-table matches for PIN/device/2FA/batch/activate-link paths, and login's success body never contains `two_factor_required`
|
||||
</acceptance_criteria>
|
||||
<done>go vet and go test -race are green across all of fonoteka.go; TestMustChangePasswordLock and TestGetApiArray both pass against a real dual-plugin boot; mail sends are asserted through the memory driver.</done>
|
||||
<done>go vet and go test -race are green across all of fonoteka.go; TestMustChangePasswordLock and TestGetApiArray both pass against a real dual-plugin boot; mail sends (with locale suffixes) are asserted through the memory driver; the blacklist sweep goroutine and the D-05 deferred-route absence are both proven by a named test.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
|
||||
@@ -501,19 +501,19 @@ func CheckAndRecordLogin(ctx context.Context, db *gorm.DB, user *models.User, ip
|
||||
| A3 | `getAvatarThumb()`'s underlying `File::getThumb($size, $size, [])` uses Winter's default resize mode (`'auto'`), matching the `mode` value already implemented in `lagoon/attach.File.Thumb` | Architecture Patterns (avatar) | If PHP's actual default mode differs (e.g. `'crop'`), the avatar thumbnail's aspect-ratio/cropping behavior would visually differ from PHP even though the endpoint and payload shape are correct — a UI regression, not a wire-contract break (the field names/URLs are unaffected). Low risk; verify against one recorded avatar-upload fixture's actual thumbnail file if pixel-parity matters, otherwise not blocking. |
|
||||
| A4 | `permissions: []`, `groups: {}`, `role: null` are the only values a real Płytarium user's `getApiArray()` payload ever produces (no roles/groups are configured in this app), so no port of Winter Storm's RBAC (`Role`, `UserGroup`) is needed | Anti-Patterns / Don't Hand-Roll | If a real user does have a group or role assigned in the live PHP data, a recorded fixture (D-11's `/_user/api/v1` capture pass) will show non-empty values and the "stub as empty" plan collapses — but this will be caught automatically the moment fixtures are recorded (D-11), before any code is written against a wrong assumption, so risk is self-correcting and low. |
|
||||
|
||||
## Open Questions
|
||||
## Open Questions (RESOLVED)
|
||||
|
||||
1. **Does PHP's `change-password` endpoint return a fresh JWT alongside the "Password changed" message?**
|
||||
1. **RESOLVED: Does PHP's `change-password` endpoint return a fresh JWT alongside the "Password changed" message?**
|
||||
- What we know: The handler body (read in full, `ApiController.php:412-515`) returns `{'message': 'Password changed', 'user': $user->getApiArray()}` — no `token` key anywhere in that response.
|
||||
- What's unclear: D-20 asks the researcher to confirm this so the "invalidate all older tokens on password change" mechanism (a per-user "valid after" timestamp) doesn't lock out the very request that just changed the password.
|
||||
- Recommendation: **Confirmed — PHP does NOT return a fresh token from change-password or reset-password.** The plan must therefore exempt the *presenting* token from the new "valid after" cutoff (set the per-user timestamp to one second before the presenting token's own `iat`, or equivalently special-case "the token used to authenticate this exact change-password call is allowed through even if it predates the new cutoff") so the calling device isn't logged out mid-session, exactly as D-20 anticipates. `reset-password` is unauthenticated (no presenting token to exempt), so its "valid after" write has no such carve-out to make.
|
||||
|
||||
2. **What is the correct `mimes` list interaction with `lagoon.Validate`'s planned `file`/`mimes` extension for the avatar endpoint specifically (vs. Phase 12's album photo needs)?**
|
||||
2. **RESOLVED: What is the correct `mimes` list interaction with `lagoon.Validate`'s planned `file`/`mimes` extension for the avatar endpoint specifically (vs. Phase 12's album photo needs)?**
|
||||
- What we know: PHP's rule is `avatar => required|file|mimes:jpeg,jpg,png,webp,gif|max:4000` (4000 KB, i.e. ~4MB) — confirmed in `ApiController.php:534-536`.
|
||||
- What's unclear: Whether the `max:4000` unit convention (KB, Laravel's default for `file`) should be reproduced as a `lagoon.Validate` token or left as a simple, separate `http.MaxBytesReader` cap at the handler/group level (which is also required regardless, per P6 D-18, to bound the multipart body before any parsing happens at all).
|
||||
- Recommendation: Use `http.MaxBytesReader` (already an established Phase 6 pattern) as the hard byte cap at the group/handler level for DoS protection, and treat `mimes:jpeg,jpg,png,webp,gif` as the only new `lagoon.Validate` token actually needed this phase — don't invent a `max` unit-conversion rule inside `lagoon.Validate` for file sizes when the existing multipart cap mechanism already covers it more cheaply.
|
||||
|
||||
3. **Exact PHP behavior for a migrated user row with `has_self_set_password = false` calling `change-password` in Go, where the OTP-bootstrap branch (428/503) is deferred (D-03).**
|
||||
3. **RESOLVED: Exact PHP behavior for a migrated user row with `has_self_set_password = false` calling `change-password` in Go, where the OTP-bootstrap branch (428/503) is deferred (D-03).**
|
||||
- What we know: D-03 states this branch is deferred with the social-login flow.
|
||||
- What's unclear: Whether such a row can exist in the *current* Płytarium production data at all (the column defaults true per the PHP model's `has_self_set_password ?? true` fallback, and CONTEXT.md states "no Go path can create a user with `has_self_set_password = false`").
|
||||
- Recommendation: Confirmed low-risk — since no Go-created row can have this flag false, and cutover-migrated PHP rows are out of this phase's scope (data migration happens at cutover, Phase 15), Phase 7's Go `change-password` handler can safely assume `has_self_set_password` is always true for any row it creates or touches, and simply 500-guard (fail loud, don't silently treat as self-set) if it ever encounters `false` — matching D-03's explicit "documented as deferred, not silently treated as self-set" instruction.
|
||||
|
||||
Reference in New Issue
Block a user