docs(07): create phase plan

Six plans for the user plugin and authentication phase:
- 07-01: bouncer JWT lifecycle, password hashing, I18N-02 locale
  override, lagoon.Validate extensions (summercms.go)
- 07-02: User/Throttle schema, core session loop (login/logout/
  fetch/refresh/register) (fonoteka.go)
- 07-03: account management (forgot/reset, activation, update,
  change-password, avatar, mail) (fonoteka.go)
- 07-04: personal API tokens, me/locale, 423-exempt route-table
  proof (fonoteka.go)
- 07-05: parity evidence recording against the isolated PHP
  instance (fonoteka.go)
- 07-06: full unit coverage and validation sign-off (both repos)

Plan count and scope confirmed at the plan-count checkpoint.
This commit is contained in:
Jakub Zych
2026-09-22 12:21:15 +02:00
parent 0c41151863
commit 57745e32a2
7 changed files with 1533 additions and 1 deletions

View File

@@ -272,7 +272,29 @@ Plans:
3. A personal API token is created with a read|write|ai scope ceiling, listed, and revoked; a scope-checking middleware rejects an out-of-scope request.
4. The must-change-password flag returns 423 on the authenticated surface except the locale and password-change routes, and locale resolves per request from the user's persisted `preferred_locale` with header fallback even while the lock is active.
**Plans**: TBD
**Plans**: 6 plans
Plans:
**Wave 1**
- [ ] 07-01-PLAN.md — bouncer JWT lifecycle, password hashing, I18N-02 locale-from-principal, lagoon.Validate extensions
**Wave 2** *(blocked on 07-01)*
- [ ] 07-02-PLAN.md — User/Throttle schema and the core session loop: login/logout/fetch/refresh/register
**Wave 3** *(blocked on 07-02)*
- [ ] 07-03-PLAN.md — Account management: forgot/reset password, activation, update, change-password, avatar, mail
- [ ] 07-04-PLAN.md — Personal API tokens (mint/list/revoke), me/locale, 423-exempt route-table proof
**Wave 4** *(blocked on 07-03, 07-04)*
- [ ] 07-05-PLAN.md — Parity evidence: record and replay the 15 /_user/api/v1 routes and the nuxt-auth flow
**Wave 5** *(blocked on 07-05)*
- [ ] 07-06-PLAN.md — Full unit coverage, 07-VALIDATION.md sign-off
### Phase 8: OAuth2.1 authorization server

View File

@@ -0,0 +1,287 @@
---
phase: 07-user-plugin-and-authentication
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- bouncer/mint.go
- bouncer/mint_test.go
- bouncer/refresh.go
- bouncer/refresh_test.go
- bouncer/blacklist.go
- bouncer/blacklist_test.go
- bouncer/jwt.go
- bouncer/jwt_test.go
- bouncer/context.go
- bouncer/context_test.go
- bouncer/registry_test.go
- bouncer/password.go
- bouncer/password_test.go
- surf/locale_from_principal.go
- surf/locale_from_principal_test.go
- surf/router.go
- lagoon/validate.go
- lagoon/validate_test.go
- go.mod
- go.sum
autonomous: false
requirements: [AUTH-01, I18N-02]
user_setup: []
must_haves:
truths:
- "A token minted by bouncer.Mint carries iss (full minting-endpoint URL), iat, exp, nbf, sub, jti and prv=a867434cbc213adfbe78a02bed7082a6bd99c883, and verifies against bouncer.Verify"
- "bouncer.Refresh accepts a token whose exp has passed but whose iat is still within refresh_ttl, and rejects one past that window, per D-07"
- "A token blacklisted with a grace window remains acceptable until valid_until, then is rejected, per D-07/D-08/Pitfall 2"
- "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)"
artifacts:
- path: "bouncer/mint.go"
provides: "Mint(secret, sub, issuerURL string, ttl time.Duration) (token, jti string, err error) with the hardcoded prv constant"
- path: "bouncer/refresh.go"
provides: "Refresh(secret, tokenString string, refreshTTL time.Duration, bl BlacklistStore, grace time.Duration, issuerURL string) (string, error) using jwt.WithoutClaimsValidation"
- path: "bouncer/blacklist.go"
provides: "BlacklistStore interface, MemoryBlacklist, PostgresBlacklist(db *sql.DB, table string) with Add/IsBlacklisted/Sweep"
- path: "bouncer/password.go"
provides: "HashPassword/CheckPassword/NeedsRehash over golang.org/x/crypto/bcrypt"
- path: "surf/locale_from_principal.go"
provides: "LocaleFromPrincipal middleware registered globally as locale.from-principal in BuildRouter"
key_links:
- from: "bouncer/jwt.go jwtGuard.Authenticate"
to: "bouncer/blacklist.go BlacklistStore.IsBlacklisted"
via: "optional bl argument on NewJWTGuard, checked when non-nil"
pattern: "IsBlacklisted"
- from: "bouncer/jwt.go jwtGuard.Authenticate"
to: "bouncer/context.go Principal.TokensValidAfter"
via: "iat comparison after FindByID, generic D-20 cutoff"
pattern: "TokensValidAfter"
---
<objective>
Ship the framework-owned (`bouncer`/`surf`/`lagoon`) primitives every later Phase 7 plan builds on: JWT minting, PHP-compatible sliding refresh, a jti blacklist with grace window, bcrypt password hashing, the `Principal.PreferredLocale`/`TokensValidAfter` fields, a post-auth locale-override middleware, and four new `lagoon.Validate` rule tokens. This is Wave 0/interface-first work — no plugin code changes yet, only the reusable contracts `golem15.user`/`golem15.fonoteka` will consume in Waves 2-3.
Purpose: every later plan (07-02..07-04) needs these signatures fixed before it can compile against them; building them first, tested in isolation, keeps the handler plans focused on wiring rather than JWT algorithm design.
Output: `bouncer.Mint`/`Refresh`/`BlacklistStore`/`HashPassword` family, extended `Principal`, `surf.LocaleFromPrincipal`, extended `lagoon.Validate`.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<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
<interfaces>
<!-- Current bouncer package shapes the executor must extend, not replace. -->
From bouncer/jwt.go (current):
```
func Middleware(secret string, users UserProvider) func(http.Handler) http.Handler // UNCHANGED, Bearer-only, kept for back-compat
func NewJWTGuard(secret string, users UserProvider) Guard // SIGNATURE CHANGES this plan
func Verify(tokenString, secret string) (string, error) // UNCHANGED, returns sub only
func bearerToken(r *http.Request) (string, error) // UNCHANGED, Bearer-only helper
```
From bouncer/context.go (current):
```go
type Principal struct {
ID uint
MustChangePassword bool
}
```
From bouncer/guard.go / registry.go (current, unchanged this plan):
```go
type Guard interface{ Authenticate(r *http.Request) (*Principal, error) }
type CredentialGuard interface{ AuthenticateCredential(r *http.Request) (*Principal, any, error) }
type UnauthorizedWriter interface{ WriteUnauthorized(w http.ResponseWriter, err error) }
func (reg *Registry) Register(pluginID, name string, g any) error
func (reg *Registry) Middleware(name string) (func(http.Handler) http.Handler, error)
```
From surf/router.go (current, `BuildRouter`): the throttle and body.limit factories are registered unconditionally near the top of `BuildRouter`, before the plugin loop:
```go
if err := r.RegisterMiddlewareFactory("surf", "throttle", func(param string) pact.Middleware { ... }); err != nil { ... }
if err := r.RegisterMiddlewareFactory("surf", "body.limit", func(param string) pact.Middleware { ... }); err != nil { ... }
```
`locale.from-principal` must be registered the same way (a plain `RegisterMiddleware`, not a factory) so every plugin can reference it in `Use(...)` without declaring it themselves.
From surf/limiter_store.go (Store interface shape to mirror for BlacklistStore):
```go
type Store interface {
Attempt(key string, max int, decay time.Duration) (allowed bool, attempts int, retryAfter time.Duration)
}
```
From lagoon/validate.go (current `validateField`, signature CHANGES this plan):
```go
func validateField(ctx context.Context, tx *gorm.DB, model any, field, rule string, val any, tr *phrasebook.Translator) ([]string, error)
```
must become (deriving `val` internally) so `confirmed`/`different` can read sibling fields:
```go
func validateField(ctx context.Context, tx *gorm.DB, model any, field, rule string, values map[string]any, tr *phrasebook.Translator) ([]string, error)
```
`Validate()`'s call site `msgs, err := validateField(ctx, tx, model, field, rule, val, tr)` becomes `validateField(ctx, tx, model, field, rule, values, tr)`.
</interfaces>
</context>
<tasks>
<task type="checkpoint:human-verify" gate="blocking">
<name>Task 1: Approve promoting golang.org/x/crypto to a direct dependency</name>
<files>go.mod, go.sum</files>
<read_first>
.planning/phases/07-user-plugin-and-authentication/07-RESEARCH.md (Package Legitimacy Audit — the slopcheck [SUS] verdict and its documented override rationale), go.mod, go.sum
</read_first>
<action>
Confirm `go.sum` in `summercms.go` already lists `golang.org/x/crypto v0.55.0` (transitively, via `testcontainers-go`/`gocloud.dev`). Confirm the source is `github.com/golang/crypto` and not a name-squatted package (`go list -m -json golang.org/x/crypto` should print origin info pointing at `go.googlesource.com/crypto`). Present RESEARCH.md's override rationale to the human reviewer — `golang.org/x/crypto` is the official Go team's extended-stdlib module, already running transitively in this codebase's own test suite today; slopcheck's `[SUS]` verdict is a documented false positive on latest-tag age and a `golang.org/x/*` vanity-import proxy-metadata gap, not an actual legitimacy concern. Do not run `go get`/`go mod tidy` until the human approves.
</action>
<verify>
<automated>go list -m -json golang.org/x/crypto</automated>
</verify>
<human-check>
Review the override rationale above. Confirm `golang.org/x/crypto/bcrypt` is the correct choice (it is the only bcrypt implementation available to Go; stdlib has none) before Task 2 imports it and this task's `go get golang.org/x/crypto@latest && go mod tidy` promotes it to direct.
</human-check>
<resume-signal>Type "approved" to promote golang.org/x/crypto to a direct dependency, or name an alternative bcrypt implementation to use instead.</resume-signal>
<acceptance_criteria>
- `go list -m -json golang.org/x/crypto` succeeds and its origin points at go.googlesource.com/crypto
- `go.sum` in summercms.go already lists golang.org/x/crypto v0.55.0 before this task runs
- Human has typed "approved" (or named an alternative) per the resume-signal
</acceptance_criteria>
<done>golang.org/x/crypto is confirmed as the correct, legitimate bcrypt source and approved for direct-dependency promotion; go.mod is updated by the end of Task 3 once password.go imports it.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: JWT lifecycle primitives — Mint, Refresh, Blacklist, Principal extension</name>
<files>bouncer/mint.go, bouncer/mint_test.go, bouncer/refresh.go, bouncer/refresh_test.go, bouncer/blacklist.go, bouncer/blacklist_test.go, bouncer/jwt.go, bouncer/jwt_test.go, bouncer/context.go, bouncer/context_test.go, bouncer/registry_test.go</files>
<read_first>
bouncer/jwt.go, bouncer/context.go, bouncer/guard.go, bouncer/registry.go, bouncer/registry_test.go, bouncer/context_test.go,
.planning/phases/07-user-plugin-and-authentication/07-RESEARCH.md (Pattern 1, Pitfall 1-4, the worked `Mint`/`Refresh`/`CheckAndRecordLogin` Code Examples, and the `prv`/`iss` hash citations),
/media/nvme/dev/golem15/fonoteka/vendor/php-open-source-saver/jwt-auth/src/Manager.php,
/media/nvme/dev/golem15/fonoteka/vendor/php-open-source-saver/jwt-auth/src/Blacklist.php,
/media/nvme/dev/golem15/fonoteka/vendor/php-open-source-saver/jwt-auth/src/Claims/{IssuedAt.php,Expiration.php,Issuer.php},
/media/nvme/dev/golem15/fonoteka/vendor/php-open-source-saver/jwt-auth/src/Validators/PayloadValidator.php,
surf/limiter_store.go (MemoryStore lazy-expiry-on-read shape to mirror for MemoryBlacklist/PostgresBlacklist)
</read_first>
<behavior>
- Test: `Mint(secret, "42", "https://app.test/_user/api/v1/login", 60*time.Minute)` returns a token whose decoded claims are exactly `iss=https://app.test/_user/api/v1/login, sub=42, prv=a867434cbc213adfbe78a02bed7082a6bd99c883`, with `iat`+`nbf` set to now and `exp` = now+ttl, and a non-empty `jti`; the returned jti string equals the token's own `jti` claim.
- Test: `bouncer.Verify` accepts a freshly minted token and returns `sub="42"`.
- Test: `Refresh` on a token whose `exp` is in the past but whose `iat` is within `refreshTTL` of now returns a new token (fresh `iat`/`exp`/`jti`, same `sub`, `prv` unchanged, new `iss`) and does not error.
- Test: `Refresh` on a token whose `iat` is older than `refreshTTL` returns an error.
- Test: `Refresh` on a token whose signature does not verify (wrong secret) returns an error even though `WithoutClaimsValidation` is used (structural/signature checks still run).
- Test: `Refresh` blacklists the OLD jti via the passed `BlacklistStore` with `validUntil = now + grace` (0 grace blacklists immediately).
- Test (blacklist): `MemoryBlacklist.Add(jti, expiresAt, validUntil)` then `IsBlacklisted(jti)` is `false` before `validUntil` and `true` at/after `validUntil` (grace-window gate, not mere row existence — Pitfall 2).
- Test (blacklist): `Sweep(now)` removes rows whose `expiresAt` has passed and leaves others.
- Test (jwtGuard): a `jwtGuard` constructed with cookie names `"token","auth_token"` and NO `Authorization` header authenticates from a `token` cookie, then falls back to `auth_token` when `token` is absent; one constructed with zero cookie names (Bearer-only) ignores cookies entirely.
- Test (jwtGuard + blacklist): a `jwtGuard` constructed with a non-nil `BlacklistStore` rejects a token whose `jti` is blacklisted, with the SAME `write401`/error shape as any other verification failure (do not introduce a new message string).
- Test (jwtGuard + TokensValidAfter): a `jwtGuard` whose `UserProvider.FindByID` returns a `Principal{TokensValidAfter: <time after the token's iat>}` rejects the token (reuse the existing `msgUserNotFound = "User not found"` 401 text — D-20's "the normal 401"); a `Principal{TokensValidAfter: <zero time>}` or one before the token's `iat` authenticates normally.
</behavior>
<action>
Create `bouncer/mint.go`: `const prvHash = "a867434cbc213adfbe78a02bed7082a6bd99c883"` (verbatim, Pitfall 4) and `func Mint(secret, sub, issuerURL string, ttl time.Duration) (token, jti string, err error)` building `jwt.RegisteredClaims{Issuer: issuerURL, Subject: sub, IssuedAt/NotBefore: now, ExpiresAt: now.Add(ttl), ID: jti}` plus a `Prv string \`json:"prv,omitempty"\`` field, signed HS256, per RESEARCH.md's worked example. `jti` generation: `crypto/rand` + hex (any unique opaque string — PHP never compares its format, only stores it).
Create `bouncer/refresh.go`: `func Refresh(secret, tokenString string, refreshTTL time.Duration, bl BlacklistStore, grace time.Duration, issuerURL string) (string, error)`. Parse with `jwt.NewParser(jwt.WithValidMethods([]string{"HS256"}), jwt.WithoutClaimsValidation())` (Pitfall 1 — never require `exp` here). Extract `jti`, `iat`, `sub` from `jwt.MapClaims`. Return an error if `iat` is more than `refreshTTL` in the past. If `bl != nil`, check `bl.IsBlacklisted(ctx, jti)` first and error if true (a forever-blacklisted — logged-out — token must never refresh). Mint a new token with the SAME `sub`, a fresh `issuerURL` (the refresh endpoint's own URL, passed by the caller — Pitfall 3, never reuse the old token's `iss`). On success, blacklist the OLD jti: `expiresAt` = the old token's own `exp` claim (fall back to `now.Add(grace)` if `exp` is unparseable), `validUntil = now.Add(grace)` (Pitfall 2 — grace defaults to 0 from the framework config key but Płytarium's app config sets 10s per D-10).
Create `bouncer/blacklist.go`: `type BlacklistStore interface { Add(ctx context.Context, jti string, expiresAt, validUntil time.Time) error; IsBlacklisted(ctx context.Context, jti string) (bool, error); Sweep(ctx context.Context, now time.Time) error }`. `MemoryBlacklist` (mutex-guarded map, for tests) mirrors `surf.MemoryStore`'s lazy-expiry-on-read shape. `PostgresBlacklist` wraps a `*sql.DB` and a caller-supplied table name (kept generic — `bouncer` must not hardcode a Płytarium-specific table; the owning plugin's migration decides the name and passes it to the constructor): `func NewPostgresBlacklist(db *sql.DB, table string) *PostgresBlacklist`. `Add` does an upsert (`INSERT ... ON CONFLICT (jti) DO UPDATE`) so a repeated logout call on the same jti does not error. `IsBlacklisted` does one indexed `SELECT valid_until FROM <table> WHERE jti = $1`; returns `true` only when a row exists AND `now >= valid_until` (Pitfall 2 — row existence alone is not enough during the grace window). `Sweep` runs `DELETE FROM <table> WHERE expires_at < $1`. Table/column identifiers come only from the constructor argument (validate with the same `identName` regex idiom `lagoon.Validate`'s `uniqueOK` uses, to avoid building a SQL string from unchecked input) — table name is a compile-time constant from the calling plugin, not user input, but validate it defensively anyway.
Extend `bouncer/jwt.go`: change `NewJWTGuard`'s signature to `func NewJWTGuard(secret string, users UserProvider, bl BlacklistStore, cookieNames ...string) Guard` (`bl` may be `nil` to skip the blacklist check; `cookieNames` empty means Bearer-only). Add an unexported `extractToken(r *http.Request, cookieNames []string) (string, error)`: try `bearerToken(r)` first; on failure, if `cookieNames` is non-empty, try each cookie name in order via `r.Cookie(name)`, first non-empty `Value` wins; if nothing found, return the existing `msgTokenNotProvided` error. Add an EXPORTED `func VerifyClaims(tokenString, secret string) (sub string, iat, exp time.Time, jti string, err error)` — same parser as `Verify` (HS256, `WithExpirationRequired`) but also extracting `iat`/`exp`/`jti` from the already-validated `jwt.MapClaims`, reusing `subject()`; this is exported deliberately (not kept package-private) because 07-02's `logout`/`refresh` handlers and 07-03's `change-password` handler all need the presenting token's `iat`/`jti`/`exp` outside the `bouncer` package — one parse implementation, no duplicated JWT-claims code in the user plugin. In `jwtGuard.Authenticate`: use `extractToken` instead of the bare `bearerToken` call, `VerifyClaims` instead of `Verify`, then after `FindByID` succeeds: if `bl != nil`, check `bl.IsBlacklisted(r.Context(), jti)` and fail with the existing generic path (reuse whatever `write401`/error text the guard already uses for "token not usable" — do not invent new wire text); then if `!principal.TokensValidAfter.IsZero() && iat.Before(principal.TokensValidAfter)`, fail with `errors.New(msgUserNotFound)` (D-20's "the normal 401"). Update every existing in-package call site of `NewJWTGuard` (tests) to pass `nil` for `bl` and no cookie names unless the test specifically exercises cookies/blacklist. Leave the top-level `Middleware` function and `Verify` untouched (C-01 — still Bearer-only, still `exp`-required, still the exact existing 401 bodies) since nothing in this phase repoints its callers.
Extend `bouncer/context.go`: add `PreferredLocale string` and `TokensValidAfter time.Time` to `Principal` (zero value = no override / no cutoff, both purely additive per RESEARCH.md's Pattern 3 and D-20).
</action>
<verify>
<automated>go vet ./... && go test ./bouncer/... -run 'TestMint|TestRefresh|TestBlacklist|TestJWTGuard|TestRegistry|TestContext' -v</automated>
</verify>
<acceptance_criteria>
- `bouncer/mint.go` contains `const prvHash = "a867434cbc213adfbe78a02bed7082a6bd99c883"`
- `Mint(secret, "42", issuerURL, ttl)` produces a token whose decoded `iss`/`sub`/`prv` equal the inputs exactly and whose `jti` matches the returned jti string
- `Refresh` on an expired-but-within-refreshTTL token returns a new token with no error; on a past-refreshTTL token returns a non-nil error
- `MemoryBlacklist.IsBlacklisted(jti)` is false before `validUntil` and true at/after it, for the same `Add` call
- `NewJWTGuard(secret, users, bl, "token", "auth_token")` authenticates a request carrying only a `token` cookie, and one carrying only `auth_token`
- `go vet ./... && go test ./bouncer/... -run 'TestMint|TestRefresh|TestBlacklist|TestJWTGuard|TestRegistry|TestContext'` exits 0
</acceptance_criteria>
<done>bouncer/{mint,refresh,blacklist}.go exist with the signatures above; Principal carries PreferredLocale and TokensValidAfter; NewJWTGuard's cookie fallback, blacklist check and TokensValidAfter cutoff are all exercised by passing tests; `go vet ./...` and `go test ./bouncer/...` are green.</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: Password hashing, post-auth locale override, and lagoon.Validate extensions</name>
<files>bouncer/password.go, bouncer/password_test.go, surf/locale_from_principal.go, surf/locale_from_principal_test.go, surf/router.go, lagoon/validate.go, lagoon/validate_test.go, go.mod, go.sum</files>
<read_first>
bouncer/context.go (PreferredLocale, now present from Task 2), surf/router.go (`BuildRouter`'s unconditional factory registration block, `locale()` middleware, `pact.Middleware` usage), towel/context.go (`WithLocale`), lagoon/validate.go (current `validateField` switch and `validateMessage`),
.planning/phases/07-user-plugin-and-authentication/07-RESEARCH.md (Pattern 3, Pitfall 6, the "Don't Hand-Roll" table's email/confirmed/different/mimes row, Open Question 2)
</read_first>
<behavior>
- Test (password): `HashPassword(10, "secret")` then `CheckPassword(hash, "secret")` is `true`, `CheckPassword(hash, "wrong")` is `false`.
- Test (password, Assumption A1): a hardcoded real PHP `$2y$10$...` bcrypt hash of a known plaintext (obtained via `php -r 'echo password_hash("golem15-a1-check", PASSWORD_BCRYPT);'` and pasted as a literal test fixture) verifies `true` against `CheckPassword` for `"golem15-a1-check"` — this is the load-bearing cross-language check RESEARCH.md's Assumption A1 asks for; keep this test permanently, not just as a one-off.
- Test (password): `NeedsRehash(hash, configuredCost)` is `true` when the hash's own cost is lower than `configuredCost`, `false` when equal or higher.
- Test (locale middleware): a request whose context already carries `bouncer.WithUser(ctx, &Principal{PreferredLocale: "pl"})` gets `towel.Locale(ctx)` == `"pl"` after `LocaleFromPrincipal` runs, overriding whatever `Accept-Language` set earlier in the pipeline.
- Test (locale middleware): a request with no `Principal` in context, or a `Principal` with `PreferredLocale == ""`, leaves the existing `towel.Locale(ctx)` value (from the header-only `locale` stage) untouched.
- Test (validate): `email` rejects `"not-an-email"` and accepts `"a@b.com"`.
- Test (validate): `confirmed` on field `password` passes when `values["password"] == values["password_confirmation"]`, fails otherwise.
- Test (validate): `different:current_password` fails when `values["password"] == values["current_password"]`, passes when they differ.
- Test (validate): `mimes:jpeg,jpg,png,webp,gif` passes when `values["avatar"]` (a string, the caller-supplied detected extension, no leading dot, case-insensitive) is a member, fails for `"svg"`.
</behavior>
<action>
Create `bouncer/password.go`: `func HashPassword(cost int, plain string) (string, error)` wrapping `bcrypt.GenerateFromPassword([]byte(plain), cost)`; `func CheckPassword(hash, plain string) bool` wrapping `bcrypt.CompareHashAndPassword` (returns `false` on any error, never panics on a malformed hash); `func NeedsRehash(hash string, configuredCost int) bool` using `bcrypt.Cost(hash)` (treat a `Cost` error as "needs rehash" — a hash bcrypt can't parse is not currently valid). Import `golang.org/x/crypto/bcrypt` (now a direct dependency per the checkpoint).
Create `surf/locale_from_principal.go`: `func LocaleFromPrincipal(next http.Handler) http.Handler` per RESEARCH.md Pattern 3 exactly — reads `bouncer.User(r.Context())`, if present and `PreferredLocale != ""` calls `r = r.WithContext(towel.WithLocale(r.Context(), p.PreferredLocale))`, then `next.ServeHTTP`. This file imports `bouncer` and `towel`; `surf` already imports both elsewhere so no import-cycle risk.
Extend `surf/router.go`'s `BuildRouter`: immediately after the existing `body.limit` factory registration (before the plugin `HasMiddleware` loop), add `if err := r.RegisterMiddleware("surf", "locale.from-principal", LocaleFromPrincipal); err != nil { return nil, err }` — so every plugin can reference `"locale.from-principal"` in `Use(...)` without declaring it. This mirrors the existing unconditional `throttle`/`body.limit` registration pattern exactly (same function, same place).
Extend `lagoon/validate.go`: change `validateField`'s signature to take the full `values map[string]any` instead of a single `val any` (update `Validate()`'s call site accordingly, deriving `val := values[field]` as `validateField`'s first line — every existing `case` keeps using the local `val` variable unchanged). Add four new cases to the token switch: `case "email":` appends `"email"` to `tags` (delegates to `go-playground/validator`'s built-in tag, already imported). `case "confirmed":` compares `val` against `values[field+"_confirmation"]` using a string-normalized equality (`fmt.Sprint` both sides, or a direct type switch matching how `val`/`numericString` already normalize elsewhere) and returns `[]string{validateMessage(ctx, tr, "confirmed", field, nil)}` on mismatch. `case "different":` (arg from `strings.Cut(tok, ":")`, already available as `arg` in the loop) compares `val` against `values[arg]` the same way and returns `validateMessage(ctx, tr, "different", field, nil)` when EQUAL. `case "mimes":` splits `arg` on `,`, compares the lowercased, dot-trimmed string form of `val` against the list, returns `validateMessage(ctx, tr, "mimes", field, nil)` when absent — this token intentionally does NOT touch file size (RESEARCH.md Open Question 2 — size stays a transport-level `http.MaxBytesReader` cap, not a `lagoon.Validate` rule). Extend `validateMessage`'s rule-name `switch` with English fallbacks for `"confirmed"`, `"different"`, `"mimes"`, `"email"` (e.g. `"The " + field + " confirmation does not match."`, `"The " + field + " and " + params["other"] + " must be different."` — simplest correct fallback text is fine, these are default-locale fallbacks per the existing `phrasebook`-first pattern, not the PHP wire text itself since these are framework validation primitives, not endpoint-specific bodies).
Run `go get golang.org/x/crypto@latest && go mod tidy` in `summercms.go` per the approved checkpoint (Task 1), confirming `go.mod` now lists `golang.org/x/crypto` as a direct (non-indirect) requirement.
</action>
<verify>
<automated>go vet ./... && go test ./bouncer/... ./surf/... ./lagoon/... -run 'TestPassword|TestNeedsRehash|TestLocaleFromPrincipal|TestValidate' -v</automated>
</verify>
<acceptance_criteria>
- `CheckPassword(HashPassword(10,"secret"), "secret")` is true; a hardcoded real PHP `$2y$` hash verifies true for its known plaintext (Assumption A1 regression test)
- `surf/router.go`'s `BuildRouter` contains a call registering `"locale.from-principal"` via `RegisterMiddleware`
- A request context carrying `Principal{PreferredLocale:"pl"}` has `towel.Locale(ctx) == "pl"` after `LocaleFromPrincipal` runs; one with `PreferredLocale:""` leaves the prior locale value untouched
- `lagoon.Validate` rejects `"not-an-email"` under an `email` rule and accepts `"a@b.com"`
- `go.mod` lists `golang.org/x/crypto` without a `// indirect` comment
- `go vet ./... && go test ./bouncer/... ./surf/... ./lagoon/... -run 'TestPassword|TestNeedsRehash|TestLocaleFromPrincipal|TestValidate'` exits 0
</acceptance_criteria>
<done>bouncer/password.go, surf/locale_from_principal.go exist and are wired into BuildRouter under the name "locale.from-principal"; lagoon.Validate accepts email/confirmed/different/mimes; golang.org/x/crypto is a direct go.mod dependency; go vet and go test are green across bouncer, surf and lagoon.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|--------------|
| Client → bouncer.Verify/jwtGuard | Untrusted bearer token / cookie value crosses into JWT parsing and claim trust decisions |
| golang.org/x/crypto supply chain | A new direct dependency crosses into the password-hashing trust boundary |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|------------------|
| T-07-01 | Spoofing / Elevation of Privilege | bouncer.jwtGuard.Authenticate | mitigate | BlacklistStore.IsBlacklisted checked on every authenticated request when a store is wired (Task 2); a forever-blacklisted (logged-out) jti is rejected with the existing generic 401 shape |
| T-07-02 | Denial of Service (self-inflicted) | bouncer.Refresh + BlacklistStore | mitigate | Grace-windowed blacklist (`valid_until`, not mere row existence) so a just-rotated token stays usable for `blacklist_grace` seconds — Pitfall 2 |
| T-07-07 | Tampering | bouncer.Verify / NewJWTGuard | accept (already mitigated, re-asserted) | Empty JWT secret already fails boot since Phase 3 (C-01); this plan does not touch secret loading, only extraction and the blacklist/cutoff checks layered on top |
| T-07-11 | Tampering / Race | bouncer.PostgresBlacklist | mitigate | `Add` is an upsert (`ON CONFLICT (jti) DO UPDATE`), `IsBlacklisted`/`Sweep` are single indexed statements — no read-then-write race window inside the store itself |
| T-07-SC | Tampering (supply chain) | golang.org/x/crypto (new direct dependency) | mitigate | Blocking `checkpoint:human-verify` (Task 1) before promotion, citing the Package Legitimacy Audit override rationale (official Go team module, already transitively present, slopcheck `[SUS]` verdict is a documented false positive) |
</threat_model>
<verification>
`go vet ./...` and `go test ./... -short` green in `summercms.go`. `go test ./bouncer/... ./surf/... ./lagoon/... -race` green. No production call site of `NewJWTGuard` changed behavior for the Bearer-only, no-blacklist, no-cutoff case (existing bouncer tests for the pre-Phase-7 shape still pass unmodified in their assertions, only their constructor call sites gain a trailing `nil`).
</verification>
<success_criteria>
Every later Phase 7 plan can import `bouncer.Mint`, `bouncer.Refresh`, `bouncer.BlacklistStore`/`NewPostgresBlacklist`, `bouncer.HashPassword`/`CheckPassword`/`NeedsRehash`, `bouncer.Principal.PreferredLocale`/`TokensValidAfter`, `surf.LocaleFromPrincipal` (registered as `"locale.from-principal"`), and the four new `lagoon.Validate` tokens without any further framework-level design work.
</success_criteria>
<output>
Create `.planning/phases/07-user-plugin-and-authentication/07-01-SUMMARY.md` when done
</output>

View File

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

View File

@@ -0,0 +1,286 @@
---
phase: 07-user-plugin-and-authentication
plan: 03
type: execute
wave: 3
depends_on: ["07-02"]
files_modified:
- ../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
autonomous: true
requirements: [AUTH-01]
must_haves:
truths:
- "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 (C-05)"
artifacts:
- path: "../fonoteka.go/plugins/golem15/user/classes/codes.go"
provides: "IssueResetCode/IssueActivationCode/VerifyResetCode/VerifyActivationCode with constant-time compare and TTL"
- path: "../fonoteka.go/plugins/golem15/user/console/require_password_change.go"
provides: "user:require-password-change <email> console command (D-21)"
- path: "../fonoteka.go/plugins/golem15/user/views/mail/{activate,restore,reactivate}(-en).htm"
provides: "the three mail templates plus layout, registered via pact.HasMailTemplates"
key_links:
- from: "../fonoteka.go/plugins/golem15/user/controllers/api_controller.go change-password handler"
to: "bouncer.Principal.TokensValidAfter / VerifyClaims"
via: "cutover set to presentingIat-1s so the calling device is not logged out mid-session (D-20, Open Question 1)"
pattern: "TokensValidAfter"
- from: "../fonoteka.go/plugins/golem15/user/controllers/api_controller.go avatar handlers"
to: "summercms.go/lagoon/attach File/Thumb"
via: "attach.File row per upload, f.Thumb(ctx, bucket, 128, 128, \"auto\") for avatar_url"
pattern: "attach\\.(File|Thumb)"
---
<objective>
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.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<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
<interfaces>
<!-- Contracts from 07-01/07-02 this plan consumes directly. -->
```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.
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Reset/activation codes and their four handlers</name>
<files>
../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
</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/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)
</read_first>
<behavior>
- 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":[...]}}`.
</behavior>
<action>
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 `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.
- `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))`.
</action>
<verify>
<automated>go vet ./... && go test ./plugins/golem15/user/... -run 'TestCodes|TestForgotPassword|TestResetPassword|TestActivate' -short</automated>
</verify>
<acceptance_criteria>
- `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
- `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>
</task>
<task type="auto" tdd="true">
<name>Task 2: Profile update, change-password with D-20 exemption, marketing consent</name>
<files>
../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
</files>
<read_first>
../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)
</read_first>
<behavior>
- 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.
</behavior>
<action>
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": <first>, "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": <apiArray>}`,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))`.
</action>
<verify>
<automated>go vet ./... && go test ./plugins/golem15/user/... -run 'TestUpdate|TestChangePassword|TestMarketingConsent' -short</automated>
</verify>
<acceptance_criteria>
- `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`
</acceptance_criteria>
<done>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.</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: Avatar upload/remove, mail templates, require-password-change command</name>
<files>
../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
</files>
<read_first>
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)
</read_first>
<behavior>
- 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 (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>
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)`.
</action>
<verify>
<automated>go vet ./... && go test ./plugins/golem15/user/... -run 'TestUploadAvatar|TestRemoveAvatar|TestMail|TestRequirePasswordChange' -short</automated>
</verify>
<acceptance_criteria>
- 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
- `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>
</task>
</tasks>
<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>
<verification>
`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.
</verification>
<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>
<output>
Create `.planning/phases/07-user-plugin-and-authentication/07-03-SUMMARY.md` when done
</output>

View File

@@ -0,0 +1,238 @@
---
phase: 07-user-plugin-and-authentication
plan: 04
type: execute
wave: 3
depends_on: ["07-02"]
files_modified:
- ../fonoteka.go/plugins/golem15/fonoteka/classes/auth/api_token_manager.go
- ../fonoteka.go/plugins/golem15/fonoteka/classes/auth/api_token_manager_test.go
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/token_api_controller.go
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/token_api_controller_test.go
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/me_locale_controller.go
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/me_locale_controller_test.go
- ../fonoteka.go/plugins/golem15/fonoteka/routes.go
- ../fonoteka.go/plugins/golem15/fonoteka/plugin.go
- ../fonoteka.go/plugins/golem15/fonoteka/routes_isolation_test.go
- ../fonoteka.go/parity/manifest.yaml
autonomous: true
requirements: [AUTH-03, AUTH-04, I18N-02]
must_haves:
truths:
- "A personal API token is minted with a read|write|ai scope ceiling (server-enforced, a client cannot request a scope outside MINTABLE_SCOPES), listed without leaking the secret, and revoked with an owner-scoped no-leak 404 for a cross-user or missing id (AUTH-03)"
- "GET/PUT me/locale is its own jwt.auth-only group (no inv.must-change-password) and persists preferred_locale (AUTH-04, I18N-02)"
- "inv.must-change-password covers exactly the JWT-authed /_fonoteka/api/v1 group's real routes and never me/locale, proven over the real assembled route table (mirrors Phase 6's mutual-exclusivity test)"
- "locale.from-principal runs after jwt.auth and before inv.must-change-password on every /_fonoteka/api/v1 JWT group, so preferred_locale resolves even while the 423 lock is active (I18N-02)"
artifacts:
- path: "../fonoteka.go/plugins/golem15/fonoteka/classes/auth/api_token_manager.go"
provides: "MintPersonalToken/RevokeToken, inv_ + base64url(32 random bytes), sha256 at rest, MintableScopes=[read,write,ai]"
- path: "../fonoteka.go/plugins/golem15/fonoteka/controllers/api/token_api_controller.go"
provides: "Store (201, plaintext once)/Index (200, secret-free)/Destroy (200 or 404) on the JWT group"
- path: "../fonoteka.go/plugins/golem15/fonoteka/controllers/api/me_locale_controller.go"
provides: "Show/Update on the jwt.auth-only me/locale group"
key_links:
- from: "../fonoteka.go/plugins/golem15/fonoteka/routes.go"
to: "summercms.go/surf LocaleFromPrincipal (registered locale.from-principal)"
via: "Use(\"jwt.auth\", \"locale.from-principal\", \"inv.must-change-password\") and Use(\"jwt.auth\", \"locale.from-principal\") on me/locale"
pattern: "locale.from-principal"
- from: "../fonoteka.go/plugins/golem15/fonoteka/classes/auth/api_token_manager.go"
to: "../fonoteka.go/plugins/golem15/fonoteka/classes ResolveActiveCollection"
via: "mint binds the new token to the caller's resolved active collection"
pattern: "ResolveActiveCollection"
---
<objective>
Complete the `golem15.fonoteka`-owned half of Phase 7: production minting/list/revoke for personal API tokens (verification already shipped in Phase 6), the `me/locale` read/write endpoints, and the wiring that proves `RequirePasswordChange` covers exactly the right routes while `I18N-02`'s locale override reaches the locked surface too.
Purpose: Phase 6 shipped the `inv_token` verifier and `inv.scope` gate but explicitly deferred minting (P6 D-07) and `me/locale`/`tokens` stayed `pending` manifest entries with empty group builders. This plan fills both in on the SAME auth-group structure Phase 6 already proved.
Output: `api_token_manager.go`, `token_api_controller.go`, `me_locale_controller.go`, the real `me/locale` and `tokens` route groups, and a route-table test proving the 423 lock's exempt set.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<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/06-http-routing-auth-groups-and-rate-limiting/06-CONTEXT.md
@.planning/phases/07-user-plugin-and-authentication/07-02-SUMMARY.md
<interfaces>
<!-- Existing Phase 6 shapes this plan extends, not replaces. -->
```go
// plugins/golem15/fonoteka/models/api_token.go (Phase 5/6, unchanged)
type ApiToken struct {
ID uint; UserID uint; Name *string; TokenHash string `json:"-"`
Scopes lagoon.Jsonable[[]string]; CollectionIDs lagoon.Jsonable[[]uint]
ExpiresAt, RevokedAt, LastUsedAt *time.Time; LastUsedIP *string; OAuthClientID *string
CreatedAt, UpdatedAt time.Time
}
func (t ApiToken) HasScope(scope string) bool
func (t ApiToken) IsUsable() bool
// plugins/golem15/fonoteka/classes/active_collection.go (Phase 3/5)
func ResolveActiveCollection(ctx context.Context, gdb *gorm.DB, userID uint) (*models.Collection, error)
// plugins/golem15/fonoteka/classes/auth/token_guard.go (Phase 6, unchanged — sha256+hex hashing convention to reuse verbatim)
const tokenPrefix = "inv_"
```
Current `routes.go` (Phase 6) already declares the two groups this plan fills in, as empty builders:
```go
r.Group("/_fonoteka/api/v1", surf.Use("jwt.auth"), func(g pact.Router) {}) // jwt_locale — becomes me/locale
r.Group("/_fonoteka/api/v1", surf.Use("jwt.auth", "inv.must-change-password"), func(g pact.Router) { g.Get("/genres", handler) }) // already carries genres; this plan adds tokens
```
`surf.RouteInfo{Method, Pattern, PluginID, Middleware, Raw}` and `(*Router).Routes() []RouteInfo` are read-only route-table snapshots (`summercms.go/surf/routetable.go`) this plan's Task 3 test walks.
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: ApiTokenManager — mint and revoke</name>
<files>
../fonoteka.go/plugins/golem15/fonoteka/classes/auth/api_token_manager.go,
../fonoteka.go/plugins/golem15/fonoteka/classes/auth/api_token_manager_test.go
</files>
<read_first>
../fonoteka.go/plugins/golem15/fonoteka/classes/auth/token_guard.go (same package — sha256+hex hashing convention MUST match exactly between mint and verify),
../fonoteka.go/plugins/golem15/fonoteka/models/api_token.go,
../fonoteka.go/plugins/golem15/fonoteka/classes/active_collection.go,
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/auth/ApiTokenManager.php (full file, already read this session — PREFIX, DEFAULT_SCOPES, MINTABLE_SCOPES, mint()/revoke()/hash()),
.planning/phases/07-user-plugin-and-authentication/07-CONTEXT.md (C-02, C-03)
</read_first>
<behavior>
- Test: `MintPersonalToken(userID, "my token", nil, nil, nil)` returns a `secret` prefixed `inv_`, base64url (no padding) after the prefix, and a `*models.ApiToken` whose `TokenHash` equals `hex.EncodeToString(sha256.Sum256([]byte(secret)))` — the EXACT format `TokenGuard.AuthenticateCredential` already hashes with (Phase 6); a token minted here must immediately verify through the existing `TokenGuard`.
- Test: `MintPersonalToken` with `scopes=nil` persists `DefaultScopes` (`["read"]`); with an explicit `["write","ai"]` persists exactly those.
- Test: minting twice never produces the same secret or hash (32 bytes of `crypto/rand` per call).
- Test: `RevokeToken` sets `revoked_at` to a non-nil, recent timestamp; a revoked token's `IsUsable()` is `false`.
</behavior>
<action>
Create `classes/auth/api_token_manager.go` (package `auth`, alongside `token_guard.go`): `const MintablePrefix = "inv_"`, `var DefaultScopes = []string{"read"}`, `var MintableScopes = []string{"read", "write", "ai"}`. `func MintPersonalToken(userID uint, name string, scopes []string, expiresAt *time.Time, collectionIDs []uint) (secret string, model *models.ApiToken, err error)`: generate `secret = MintablePrefix + base64.RawURLEncoding.EncodeToString(raw32BytesFromCryptoRand)` (matches PHP's `rtrim(strtr(base64_encode(...),'+/','-_'),'=')` exactly — `base64.RawURLEncoding` already IS unpadded URL-safe base64, no manual `strtr`/`rtrim` needed in Go). Hash with `sha256.Sum256` + `hex.EncodeToString`, IDENTICAL to `token_guard.go`'s existing hashing (do not reimplement — extract a tiny shared `hashToken(secret string) string` helper in this package if `token_guard.go` doesn't already expose one, and have both call sites use it). Build `&models.ApiToken{UserID: userID, Name: &name, TokenHash: hash, Scopes: lagoon.NewJsonable(scopesOrDefault), ExpiresAt: expiresAt}`; if `collectionIDs` is non-empty, dedupe/sort and set `CollectionIDs`. Caller (Task 2) does `db.Create(model)`. `func RevokeToken(db *gorm.DB, token *models.ApiToken) error`: `db.Model(token).Update("revoked_at", time.Now())`.
</action>
<verify>
<automated>go vet ./... && go test ./plugins/golem15/fonoteka/classes/auth/... -run TestMintPersonalToken -short</automated>
</verify>
<acceptance_criteria>
- `MintPersonalToken`'s returned secret is prefixed `inv_` and the model's `TokenHash` equals `hex.EncodeToString(sha256.Sum256([]byte(secret)))`
- A token minted by `MintPersonalToken` immediately authenticates through the existing `TokenGuard.AuthenticateCredential`
- `scopes=nil` persists exactly `["read"]`; two calls never produce the same secret
- `RevokeToken` sets a non-nil `revoked_at` and `IsUsable()` becomes false
</acceptance_criteria>
<done>MintPersonalToken's hash format is byte-identical to what TokenGuard verifies (proven by a round-trip test); MINTABLE_SCOPES/DefaultScopes match PHP's constants.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Token CRUD controller, me/locale controller, route groups, locale wiring</name>
<files>
../fonoteka.go/plugins/golem15/fonoteka/controllers/api/token_api_controller.go,
../fonoteka.go/plugins/golem15/fonoteka/controllers/api/token_api_controller_test.go,
../fonoteka.go/plugins/golem15/fonoteka/controllers/api/me_locale_controller.go,
../fonoteka.go/plugins/golem15/fonoteka/controllers/api/me_locale_controller_test.go,
../fonoteka.go/plugins/golem15/fonoteka/routes.go,
../fonoteka.go/plugins/golem15/fonoteka/plugin.go
</files>
<read_first>
../fonoteka.go/plugins/golem15/fonoteka/controllers/genre_controller.go (handler-factory, writeJSON/writeOpaque500, bouncer.User(ctx) — this route lives on the group-level jwt.auth, so auth is already resolved by the time the handler runs, UNLIKE golem15.user's per-handler style),
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/TokenApiController.php (full file, already read this session),
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/MeLocaleController.php (full file, already read this session),
.planning/phases/07-user-plugin-and-authentication/07-CONTEXT.md (C-02, C-03, discretion note on 423-exempt grouping),
.planning/phases/07-user-plugin-and-authentication/07-RESEARCH.md (Pattern 3 — I18N-02 middleware placement)
</read_first>
<behavior>
- Test (Store, 201): `{"name":"laptop"}` mints a token bound to the caller's resolved active collection, returns `{"token":"inv_...","meta":{"id":...,"name":"laptop","scopes":["read"],"collection_ids":[<active id>],"collections":[{"id":...,"name":"..."}],"last_used_at":null,"last_used_ip":null,"expires_at":null,"revoked_at":null,"created_at":"..."}}`, status 201; the response NEVER contains `token_hash`.
- Test (Store, 422): a scope outside `read|write|ai` (e.g. `"admin"`) is rejected before any row is persisted; a missing `name` is rejected.
- Test (Index, 200): returns `{"data":[<meta for each non-revoked, non-oauth-bound token>],"connected_apps_count":<count of oauth_client_id-bound, non-revoked tokens, always 0 this phase since nothing mints one yet>}`, newest first, secret-free.
- Test (Destroy): the owner revoking their own token returns 200 `{"data":{"revoked":true}}`, and that token subsequently fails `TokenGuard` verification; a cross-user id or a nonexistent id returns 404 `{"error":"Token not found"}` (owner-scoped `WHERE user_id = ?` — a cross-user id must never distinguish "exists but not yours" from "does not exist").
- Test (me/locale Show): returns `{"preferred_locale": "<value>"}` for the JWT-authed caller.
- Test (me/locale Update): `{"locale":"en"}` persists and returns `{"preferred_locale":"en"}`; `{"locale":"de"}` (not in `pl,en`) returns 422 `{"error":"Validation failed","errors":{"locale":[...]}}`.
- Test (I18N-02 ordering): a caller with `PreferredLocale="pl"` hitting `me/locale` while ALSO carrying `must_change_password=true` still succeeds (locale.from-principal and the me/locale group itself both run before/without `inv.must-change-password`), proving the lock never blocks locale persistence.
</behavior>
<action>
Create `controllers/api/token_api_controller.go` (package `api`, new subdirectory under `controllers/`, matching the `controllers/api/` layout PATTERNS.md and the `MeLocaleController.php`/`TokenApiController.php` PHP namespace both already imply): `Store(app)` — validate `name` (`required`, max 255 via `lagoon.Validate`'s existing `max` token), and for each element of an optional `scopes` array, reject any value outside `auth.MintableScopes` (manual loop — `lagoon.Validate` has no wildcard-array rule; shape the 422 body as `{"error":"Validation failed","errors":{"scopes":[...]}}`, adjust the exact key during 07-05 fixture recording if the live PHP uses `scopes.0` instead). Resolve `active, err := classes.ResolveActiveCollection(ctx, tx, user.ID)`; call `auth.MintPersonalToken(user.ID, name, scopes, expiresAt, []uint{active.ID})`, `db.Create(model)`. Serialize via a `serializeToken(t *models.ApiToken) map[string]any` helper (id, name, scopes, collection_ids via `t.CollectionIDs.Get()`, collections resolved by `Collection.Where("id IN ?", ids).Select("id,name")`, last_used_at/last_used_ip/expires_at/revoked_at/created_at as ISO8601 or `null`) — `Store` returns `{"token": secret, "meta": serializeToken(model)}`, 201.
`Index(app)`: `ApiToken.Where("user_id = ? AND oauth_client_id IS NULL AND revoked_at IS NULL", user.ID).Order("created_at DESC")`, map each through `serializeToken`; separately count `WHERE user_id = ? AND oauth_client_id IS NOT NULL AND revoked_at IS NULL` for `connected_apps_count`. `{"data": [...], "connected_apps_count": n}`, 200 (empty list serializes `[]`, never `null` — use `wire.Slice`).
`Destroy(app)`: `ApiToken.Where("user_id = ?", user.ID).First(&token, id)`; not found → `{"error":"Token not found"}`,404. Found: `auth.RevokeToken(db, &token)` (the OAuth-refresh-token revocation chain PHP's controller runs when `oauth_client_id` is set is Phase 8 scope — no Go code mints an oauth-bound token yet, so this branch is unreachable this phase; do not build `OAuthCodeManager` to handle it, just call `RevokeToken` unconditionally). `{"data":{"revoked":true}}`,200.
Create `controllers/api/me_locale_controller.go`: `Show(app)` — `bouncer.User(r.Context())` (group middleware already resolved it), `{"preferred_locale": user.PreferredLocale}`,200. `Update(app)` — validate `locale` (`required|in:pl,en`) via `lagoon.Validate`; failure → `{"error":"Validation failed","errors":{"locale":[...]}}`,422; success → persist, `{"preferred_locale": locale}`,200.
Extend `routes.go`: replace the empty `jwt_locale` group body with `g.Get("/me/locale", api.Show(p.app)); g.Put("/me/locale", api.Update(p.app))` and its `Use(...)` list with `surf.Use("jwt.auth", "locale.from-principal")` (NO `inv.must-change-password` — this group must stay reachable while locked, per D-01/AUTH-04). Add `g.Post("/tokens", api.Store(p.app)); g.Get("/tokens", api.Index(p.app)); g.Delete("/tokens/{id}", api.Destroy(p.app))` with `Where("id", "[0-9]+")` on the delete route, to the EXISTING genres group whose `Use(...)` becomes `surf.Use("jwt.auth", "locale.from-principal", "inv.must-change-password")` (insert `"locale.from-principal"` as the second entry, right after `"jwt.auth"`, before `"inv.must-change-password"` — this is the concrete I18N-02 placement RESEARCH.md's Pattern 3 specifies).
No `plugin.go` change is needed for `locale.from-principal` itself — it is registered globally by `surf.BuildRouter` in 07-01 under that exact name; this task only REFERENCES it in `Use(...)`.
</action>
<verify>
<automated>go vet ./... && go test ./plugins/golem15/fonoteka/... -run 'TestTokenApi|TestMeLocale' -short</automated>
</verify>
<acceptance_criteria>
- `Store` returns 201 with `token` present and `meta` containing no `token_hash` key
- `Store` with a scope outside `read|write|ai` is rejected (422) before any row is persisted
- `Index` returns `{"data":[...],"connected_apps_count":n}` with `data` serializing `[]` when empty, never `null`
- `Destroy` on a cross-user or nonexistent id returns 404 `{"error":"Token not found"}`; on the owner's own token returns 200 `{"data":{"revoked":true}}`
- `me/locale` `PUT {"locale":"en"}` returns 200 `{"preferred_locale":"en"}`; `{"locale":"de"}` returns 422
- `surf.RouteInfo` for `GET/PUT /_fonoteka/api/v1/me/locale` lists `jwt.auth` and `locale.from-principal` but NOT `inv.must-change-password`
</acceptance_criteria>
<done>Token mint/list/revoke and me/locale GET/PUT all match the behaviors above; the JWT genres+tokens group and the me/locale group both carry locale.from-principal immediately after jwt.auth; only the genres+tokens group carries inv.must-change-password.</done>
</task>
<task type="auto">
<name>Task 3: Route-table 423-exempt assertion and manifest flip</name>
<files>../fonoteka.go/plugins/golem15/fonoteka/routes_isolation_test.go, ../fonoteka.go/parity/manifest.yaml</files>
<read_first>
../fonoteka.go/plugins/golem15/fonoteka/routes_isolation_test.go (TestFullRouteTableAuthGroupMutualExclusivity — the exact BuildRouter-over-real-plugins pattern to extend, not replace),
summercms.go/surf/routetable.go,
../fonoteka.go/parity/manifest.yaml (the existing `me/locale`/`tokens` pending entries, lines ~14-52 and ~1611-1667, already read this session),
.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-01-SUMMARY.md (Deviation #2 — the precedent for aligning a stale capture-session fixture body to the real ported handler's output, rather than weakening the assertion)
</read_first>
<action>
Extend `routes_isolation_test.go` with `TestRequirePasswordChangeExemptSet` (same `BuildRouter`-over-real-plugins pattern as `TestFullRouteTableAuthGroupMutualExclusivity`): walk `rt.Routes()`; for every route whose `Pattern` starts with `/_fonoteka/api/v1` — assert `inv.must-change-password` is present in `Middleware` UNLESS `Pattern == "/_fonoteka/api/v1/me/locale"` (both GET and PUT); assert NO route whose `Pattern` starts with `/_user/api/v1` ever carries `jwt.auth` or `inv.must-change-password` (D-01 — that group has neither, auth is per-handler). Assert both `me/locale` routes DO carry `jwt.auth` and `locale.from-principal`, and the `tokens` routes carry `jwt.auth`, `locale.from-principal`, AND `inv.must-change-password`.
Run the existing recorded fixtures for the five previously-`pending` routes (`GET|PUT /_fonoteka/api/v1/me/locale`, `POST|GET /_fonoteka/api/v1/tokens`, `DELETE /_fonoteka/api/v1/tokens/{id}`) against the new Go handlers via `go test ./parity/... -run TestParityCorpus`. Where a fixture body is a stale capture-session artifact that doesn't match what the CURRENT Go handler correctly produces (e.g. a `POST /tokens` 422 fixture recorded before `name` validation existed, or a `GET /tokens` body shape mismatch), align the FIXTURE to the real handler output — mirroring the 06-01-SUMMARY Deviation #2 precedent exactly (fix the stale fixture, do not weaken the Go response to match a wrong recording). If a fixture instead reveals a genuine Go behavior gap (e.g. missing `collections` key), fix the Go handler, not the fixture. Once every one of the five cases replays green, flip their `status: pending` to `status: ported` in `parity/manifest.yaml` (edit the five existing blocks in place — do not create new manifest entries for routes that already have one). Update `parity_test.go`'s `expectedPortedRoutes` constant from `2` to `7` (genres x2 + tokens x3 + me/locale x2 — wait, that is 2+3+2=7, confirm the exact arithmetic against the manifest's actual current ported count via `grep -c 'status: ported' parity/manifest.yaml` rather than trusting this comment blindly) and `parity_contract_test.go`'s hardcoded inventory assertion (`cov.Recorded != 154 || cov.Ported != 2 || cov.Pending != 152`) to the new ported/pending split — `expectedPHPRoutes` itself stays `154` this plan (the fifteen new `/_user/api/v1` routes are 07-05's addition, not this plan's).
</action>
<verify>
<automated>go vet ./... && go test ./plugins/golem15/fonoteka/... -run TestRequirePasswordChangeExemptSet && go test ./parity/... -run TestParityCorpus -short</automated>
</verify>
<acceptance_criteria>
- `TestRequirePasswordChangeExemptSet` walks the real assembled route table and fails if any `/_fonoteka/api/v1` route other than `me/locale` lacks `inv.must-change-password`, or if `me/locale` carries it
- The same test fails if any `/_user/api/v1` route carries `jwt.auth` or `inv.must-change-password`
- `parity/manifest.yaml`'s five `tokens`/`me/locale` entries all read `status: ported`
- `go test ./parity/... -run TestParityCorpus -short` exits 0 with those five cases passing
</acceptance_criteria>
<done>The route-table test proves the 423 lock's exempt set is exactly {me/locale GET, me/locale PUT}; the five tokens/me-locale manifest entries read status: ported and their recorded fixtures replay green against the real handlers.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|--------------|
| Client → token mint | An authenticated but potentially malicious client crosses into scope-ceiling enforcement |
| Client → token revoke | Untrusted numeric id crosses into an owner-scoped lookup |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|------------------|
| T-07-06 | Elevation of Privilege | token_api_controller.Store | mitigate | Server-side `MintableScopes` allow-list rejects any scope outside `read/write/ai` regardless of client input — no client-suppliable escape hatch |
| T-07-08 | Elevation of Privilege | 423 lock route grouping | mitigate | `TestRequirePasswordChangeExemptSet` asserts the exempt set over the REAL assembled route table, not by convention — a future route accidentally added to the wrong group fails this test at boot-equivalent time |
| IDOR (unlabeled, folded into V4 Access Control) | Information Disclosure | token_api_controller.Destroy | mitigate | Owner-scoped `WHERE user_id = ?` lookup; a cross-user or missing id both produce the identical 404, no enumeration signal |
</threat_model>
<verification>
`go vet ./...` and `go test ./... -short` green in `fonoteka.go`. `go test ./parity/... -run TestParityCorpus` reports the five previously-pending routes as passing/ported, corpus totals otherwise unchanged from 07-02/07-03's state.
</verification>
<success_criteria>
Personal API tokens can be minted, listed and revoked entirely through `/_fonoteka/api/v1/tokens`; `me/locale` persists `preferred_locale` and stays reachable under the 423 lock; the lock's exempt set is proven, not assumed.
</success_criteria>
<output>
Create `.planning/phases/07-user-plugin-and-authentication/07-04-SUMMARY.md` when done
</output>

View File

@@ -0,0 +1,205 @@
---
phase: 07-user-plugin-and-authentication
plan: 05
type: execute
wave: 4
depends_on: ["07-03", "07-04"]
files_modified:
- ../fonoteka.go/parity/manifest.yaml
- ../fonoteka.go/parity/capture-rules.yaml
- ../fonoteka.go/parity/parity_test.go
- ../fonoteka.go/parity/check_corpus.go
- ../fonoteka.go/parity/db_capture.go
- ../fonoteka.go/parity/db_capture_test.go
- ../fonoteka.go/parity/fixtures/routes/POST___user_api_v1_login_user-api.yaml
- ../fonoteka.go/parity/fixtures/routes/POST___user_api_v1_logout_user-api.yaml
- ../fonoteka.go/parity/fixtures/routes/GET___user_api_v1_fetch_user-api.yaml
- ../fonoteka.go/parity/fixtures/routes/POST___user_api_v1_refresh_user-api.yaml
- ../fonoteka.go/parity/fixtures/routes/POST___user_api_v1_register_user-api.yaml
- ../fonoteka.go/parity/fixtures/routes/POST___user_api_v1_forgot-password_user-api.yaml
- ../fonoteka.go/parity/fixtures/routes/POST___user_api_v1_reset-password_user-api.yaml
- ../fonoteka.go/parity/fixtures/routes/POST___user_api_v1_activate_user-api.yaml
- ../fonoteka.go/parity/fixtures/routes/POST___user_api_v1_activate-by-code_user-api.yaml
- ../fonoteka.go/parity/fixtures/routes/POST___user_api_v1_update_user-api.yaml
- ../fonoteka.go/parity/fixtures/routes/POST___user_api_v1_change-password_user-api.yaml
- ../fonoteka.go/parity/fixtures/routes/POST___user_api_v1_avatar_user-api.yaml
- ../fonoteka.go/parity/fixtures/routes/POST___user_api_v1_avatar_remove_user-api.yaml
- ../fonoteka.go/parity/fixtures/routes/POST___user_api_v1_marketing-consent_user-api.yaml
- ../fonoteka.go/parity/fixtures/routes/GET___user_api_v1_oauth-providers_user-api.yaml
- ../fonoteka.go/parity/fixtures/nuxt/nuxt-auth.yaml
autonomous: false
requirements: [AUTH-01, AUTH-02, AUTH-03, AUTH-04]
must_haves:
truths:
- "Every one of the 15 /_user/api/v1 routes named in D-01 is recorded against the isolated PHP instance and replays green against the Go backend"
- "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)"
artifacts:
- path: "../fonoteka.go/parity/fixtures/nuxt/nuxt-auth.yaml"
provides: "the D-14 recorded client flow"
- path: "../fonoteka.go/parity/manifest.yaml"
provides: "15 new /_user/api/v1 entries plus every D-13 distinct-body case, status: ported once green"
key_links:
- from: "../fonoteka.go/parity/db_capture.go"
to: "../fonoteka.go/parity/capture-rules.yaml two-step flows"
via: "reads reset_password_code/activation_code from the target's own users row after step one, writes it into the shared tide.Store as a {{var}}"
pattern: "reset_password_code|activation_code"
---
<objective>
Record real parity evidence for every route 07-02/07-03/07-04 shipped: the 15 `/_user/api/v1` routes (new manifest entries, D-11) plus the `nuxt-auth` client flow (D-14), against the isolated PHP instance via the Phase 2 `tide` tooling, then replay them green against the Go backend. This plan is the acceptance test for AUTH-01..04 — until it is green, "the PHP contract is the acceptance test" is only a design intent for this phase, not a proven fact.
Purpose: prove byte-for-byte parity on the newly-ported surface, including the two hardest-to-fake cases: a real Winter throttle confirmation (Assumption A2) and code-carrying two-step mail flows (D-12) that need a real database read, not a stubbed value.
Output: 15 new `manifest.yaml` entries plus their fixtures, an extended `capture-rules.yaml`, a DB-reading capture helper, and `nuxt-auth.yaml`.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<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-VALIDATION.md
@.planning/phases/07-user-plugin-and-authentication/07-02-SUMMARY.md
@.planning/phases/07-user-plugin-and-authentication/07-03-SUMMARY.md
@.planning/phases/07-user-plugin-and-authentication/07-04-SUMMARY.md
<interfaces>
<!-- tide's exported vars-store API (summercms.go/tide/variables.go), the mechanism the DB-capture hook writes through. -->
```go
func tide.OpenStore(path string) (*Store, error) // loads/creates a private 0600 YAML map; empty path = memory-only
func (s *Store) Get(name string) (string, bool)
func (s *Store) Set(name, value string)
func (s *Store) Save() error // writes YAML, mode 0600
func (s *Store) Expand(text string) (string, error) // {{name}} substitution used by request specs
```
`summer parity:record`/`parity:replay` CLI flags (summercms.go/cmd/summer/parity.go, already read this session): `--spec/--target/--output/--rules/--vars/--manifest/--fixtures/--update/--next-batch(max 15)/--resume/--allow-incomplete/--require-recorded`.
`../fonoteka.go/parity/php_parity.sh {reset|serve|artisan}` (already read this session): `reset` wipes and recreates the isolated SQLite DB and runs `winter:up`; `serve` runs the isolated PHP app at `127.0.0.1:8423` with `APP_DEBUG=false`; `artisan <args>` runs any artisan command (e.g. `tinker`) against the SAME isolated DB — useful for a one-off read without a Go SQLite driver.
</interfaces>
</context>
<tasks>
<task type="checkpoint:human-action" gate="blocking">
<name>Task 1: Start the isolated PHP instance</name>
<files>../fonoteka.go/parity/php_parity.sh</files>
<read_first>../fonoteka.go/parity/php_parity.sh (reset/serve/artisan subcommands and the refuse_db guard, already read this session)</read_first>
<action>
Run `../fonoteka.go/parity/php_parity.sh reset` (wipes the parity-only SQLite DB, runs `winter:up`). Run `../fonoteka.go/parity/php_parity.sh serve &` (or in a separate terminal) so it stays up for the rest of this plan. Confirm this is the ISOLATED parity database (`realpath $PARITY_ROOT`), never the developer's own `storage/database.sqlite` -- `php_parity.sh`'s own `refuse_db` guard already enforces this, but visually confirm the printed DB path before proceeding.
</action>
<verify>
<automated>curl -sf http://127.0.0.1:8423/_user/api/v1/oauth-providers</automated>
</verify>
<human-check>
Confirm the isolated PHP instance is running against the parity-only SQLite database, not the developer's own database, before any recording begins.
</human-check>
<resume-signal>Type "ready" once the isolated PHP instance is reachable at 127.0.0.1:8423.</resume-signal>
<acceptance_criteria>
- `curl -sf http://127.0.0.1:8423/_user/api/v1/oauth-providers` returns HTTP 200 with a JSON body
- The printed `DB_DATABASE` path from `php_parity.sh` lives under `$PARITY_ROOT` and is named `*parity*`, never the developer's own database
</acceptance_criteria>
<done>The isolated PHP instance is reachable at 127.0.0.1:8423 and backed by the parity-only SQLite database.</done>
</task>
<task type="auto">
<name>Task 2: Record the 15 /_user/api/v1 routes and the nuxt-auth flow</name>
<files>
../fonoteka.go/parity/manifest.yaml, ../fonoteka.go/parity/capture-rules.yaml, ../fonoteka.go/parity/db_capture.go, ../fonoteka.go/parity/db_capture_test.go,
../fonoteka.go/parity/fixtures/routes/POST___user_api_v1_*.yaml, ../fonoteka.go/parity/fixtures/routes/GET___user_api_v1_*.yaml, ../fonoteka.go/parity/fixtures/nuxt/nuxt-auth.yaml
</files>
<read_first>
../fonoteka.go/parity/capture-rules.yaml (existing `login` capture rule to extend), ../fonoteka.go/parity/manifest.yaml (existing entry block shape — `id, method, path, auth_group, status, identities, cases[].fixture/request/response`),
../fonoteka.go/parity/fixtures/routes/GET__api_v1_fonoteka_genres_personal_token.yaml (single-route fixture shape), ../fonoteka.go/parity/fixtures/nuxt/nuxt-browse.yaml (first ~50 lines only — the client-flow fixture step shape and `{{jwt:alice}}`/`{{id:*}}` placeholder syntax),
summercms.go/tide/variables.go (Store — full file, the exported API this task's DB-capture helper writes through),
.planning/phases/07-user-plugin-and-authentication/07-CONTEXT.md (D-01, D-02, D-06 through D-21 — every response body this task must record matches what 07-02/07-03 already implemented),
.planning/phases/07-user-plugin-and-authentication/07-VALIDATION.md (Manual-Only Verifications table — A1/A2 confirmation instructions)
</read_first>
<action>
Extend `capture-rules.yaml` with capture blocks for `register`, `refresh`, and `activate-by-code` (each mints a token, Pitfall 3): `{method: POST, path: /_user/api/v1/register, capture: [{from: response.json, path: $.token, as: jwt:newuser, category: jwt}]}` and equivalents for the other two — mirror the existing `login` rule's shape exactly.
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 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).
Record the two D-12 two-step flows using `db_capture.go`'s helper between steps: `forgot-password` (step 1) → read `reset_password_code` via the helper → `reset-password` (step 2) with `{{code:reset}}` in its request body. `register` in `user`-activation mode (step 1) → read `activation_code` → `activate-by-code` (step 2) with `{{code:activate}}`.
Author `fixtures/nuxt/nuxt-auth.yaml` per D-14: `register → fetch → update → change-password → refresh → logout → (reuse of the logged-out token refused)`, PLUS a second scenario in the same flow (or a second flow file if that's cleaner given the existing `nuxt-browse.yaml` convention — check how multi-scenario flows are structured there first): a `must_change_password` user hits 423 on an authenticated `/_fonoteka/api/v1` route, then successfully calls `me/locale`, then clears the lock via `change-password`. Use `{{jwt:newuser}}`/`{{id:*}}` placeholders exactly per `nuxt-browse.yaml`'s established syntax.
Add all 15 new manifest entries (they do not exist yet, unlike `tokens`/`me/locale` which 07-04 already flipped) with `auth_group: user-api` (a new auth-group label distinct from `jwt`/`personal_token`/`public` — the `/_user/api/v1` group has no `jwt.auth`, matching D-01), one `cases[]` entry per distinct status+body recorded. Set `status: ported` only for routes whose EVERY recorded case replays green against the Go backend this session — anything that doesn't reach full green stays `pending` (never claim ported-but-failing, matching the established "pending never equals passing" rule).
Update `parity_test.go`'s `expectedPHPRoutes` from `154` to `169` (154 + 15) and `check_corpus.go`'s `expectedRouteCount` the same way; update `parity_contract_test.go`'s hardcoded inventory numbers to the real recorded/ported/pending split this session produces (read the actual numbers off the corpus run, do not guess). Leave `ROADMAP.md`/`REQUIREMENTS.md`'s "All 154 routes" phrasing (API-09, QA-05, the Phase 15 goal) UNCHANGED — that figure is specifically the Płytarium `fonoteka` plugin's route surface for the cutover criterion, a stable definition unaffected by `golem15.user`'s own, separately-tracked routes sharing the same manifest FILE.
</action>
<verify>
<automated>go test ./parity/... -run 'TestParityCorpus|TestDBCapture' -short</automated>
</verify>
<acceptance_criteria>
- `parity/manifest.yaml` contains exactly 15 new entries with `auth_group: user-api`, one per D-01 route
- Every one of the 15 entries has at least one recorded `cases[]` fixture; routes with a documented failure mode (bad credentials, throttled, disabled registration, expired code) have that case recorded too
- 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`
</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>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<name>Task 3: Verify no secrets leaked and the corpus replays green</name>
<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.
</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>
</verify>
<human-check>
Confirm no live secret is committed and the corpus replays green before approving this plan, or list the specific fixtures/bodies that need a gap-closure follow-up.
</human-check>
<resume-signal>Type "approved" once no live secret is committed and the corpus replays green, or list the specific fixtures/bodies that need a gap-closure follow-up.</resume-signal>
<acceptance_criteria>
- 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
</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>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|--------------|
| Parity recorder → isolated PHP | The recorder captures real HTTP traffic, including credential and token material, that must never reach git |
| Parity recorder → committed fixtures | Recorded bodies cross from a private, secret-bearing capture session into a public, committed artifact |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|------------------|
| T-07-10 | Information Disclosure | Recorded fixtures | mitigate | Capture-rule masking (existing Phase 2 mechanism) plus this plan's explicit human-verify grep pass for JWT/`inv_` shapes before commit; the private vars store stays 0600 and untracked |
| T-07-14 | Information Disclosure | DB-capture helper (`db_capture.go`) | mitigate | Reads only the single `reset_password_code`/`activation_code` column needed, via a parameterized query (Postgres side) or a fixed `tinker` one-liner with no user-controlled SQL (PHP side) — never a general-purpose DB shell |
</threat_model>
<verification>
`go test ./parity/... -run TestParityCorpus -v` is green with the new routes reflected in the coverage summary; `summer parity:replay` reports zero failing cases across the whole manifest (existing 154-route fonoteka surface unaffected, new 15-route user surface green or honestly pending).
</verification>
<success_criteria>
Every `/_user/api/v1` route and the `nuxt-auth` flow are recorded against real PHP and replay green against the Go backend, with A2 and D-12 settled empirically rather than assumed.
</success_criteria>
<output>
Create `.planning/phases/07-user-plugin-and-authentication/07-05-SUMMARY.md` when done
</output>

View File

@@ -0,0 +1,189 @@
---
phase: 07-user-plugin-and-authentication
plan: 06
type: execute
wave: 5
depends_on: ["07-05"]
files_modified:
- bouncer/mint_test.go
- bouncer/refresh_test.go
- bouncer/blacklist_test.go
- bouncer/password_test.go
- bouncer/jwt_test.go
- surf/locale_from_principal_test.go
- lagoon/validate_test.go
- ../fonoteka.go/plugins/golem15/user/controllers/api_controller_test.go
- ../fonoteka.go/plugins/golem15/user/classes/throttle_test.go
- ../fonoteka.go/plugins/golem15/user/classes/codes_test.go
- ../fonoteka.go/plugins/golem15/user/classes/events_test.go
- ../fonoteka.go/plugins/golem15/user/updates/user_session_test.go
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/token_api_controller_test.go
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/me_locale_controller_test.go
- ../fonoteka.go/plugins/golem15/fonoteka/routes_isolation_test.go
- ../fonoteka.go/plugins/golem15/fonoteka/classes/auth/api_token_manager_test.go
- .planning/phases/07-user-plugin-and-authentication/07-VALIDATION.md
autonomous: true
requirements: [AUTH-01, AUTH-02, AUTH-03, AUTH-04, I18N-02]
must_haves:
truths:
- "go vet ./... and go test ./... -race are green in both summercms.go and fonoteka.go (QA-03)"
- "Every row in 07-VALIDATION.md's Per-Task Verification Map has a passing automated command, Task IDs are filled in, and nyquist_compliant is true"
- "The C-01/C-02/C-03/C-04/C-05 carried-forward decisions and every D-01..D-21 decision this phase implemented have at least one passing test asserting the behavior they describe"
- "postcard's memory driver proves mail.activate/mail.restore/mail.reactivate actually send with the right vars, closing the loop RESEARCH.md's Wave 0 gaps opened"
artifacts:
- path: ".planning/phases/07-user-plugin-and-authentication/07-VALIDATION.md"
provides: "Task IDs filled in, nyquist_compliant: true, wave_0_complete: true"
key_links:
- from: "07-06 test suite"
to: "07-01..07-05 implementation"
via: "every Wave 0 gap listed in 07-VALIDATION.md now has a corresponding passing test file"
pattern: "Wave 0"
---
<objective>
Close every Wave 0 test gap 07-VALIDATION.md listed, bring `go vet`/`go test -race` green across both repos, and finalize the validation contract so `/gsd:secure-phase 7` has a clean, fully-tested baseline to review. This is the mandatory last plan of the phase per the project's lean-mode workflow rule.
Purpose: earlier plans wrote focused, task-scoped tests (TDD `behavior` blocks); this plan is the systematic pass that fills any remaining coverage hole — cross-cutting flows (full login→refresh→logout sequences, the D-20 exemption under concurrent access, `TestMustChangePasswordLock`'s full route-table proof) that don't naturally belong inside a single earlier task.
Output: green `go vet`/`go test -race` in both modules, and a `07-VALIDATION.md` with `nyquist_compliant: true`.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<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-VALIDATION.md
@.planning/phases/07-user-plugin-and-authentication/07-01-SUMMARY.md
@.planning/phases/07-user-plugin-and-authentication/07-02-SUMMARY.md
@.planning/phases/07-user-plugin-and-authentication/07-03-SUMMARY.md
@.planning/phases/07-user-plugin-and-authentication/07-04-SUMMARY.md
@.planning/phases/07-user-plugin-and-authentication/07-05-SUMMARY.md
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Complete summercms.go coverage — bouncer, surf, lagoon</name>
<files>bouncer/mint_test.go, bouncer/refresh_test.go, bouncer/blacklist_test.go, bouncer/password_test.go, bouncer/jwt_test.go, surf/locale_from_principal_test.go, lagoon/validate_test.go</files>
<read_first>
bouncer/mint.go, bouncer/refresh.go, bouncer/blacklist.go, bouncer/password.go, bouncer/jwt.go, bouncer/context.go, surf/locale_from_principal.go, lagoon/validate.go (all from 07-01, read the CURRENT state — not the plan text — since Task 3 of 07-01 may have adjusted details during execution),
.planning/phases/07-user-plugin-and-authentication/07-VALIDATION.md (Wave 0 Requirements: `bouncer/{mint,refresh,blacklist}_test.go`, `surf/locale_from_principal_test.go`)
</read_first>
<behavior>
- Any behavior from 07-01's Task 2/3 `<behavior>` blocks not already covered by a passing test gets one now (cross-check by running `go test ./bouncer/... ./surf/... ./lagoon/... -cover` and reading the coverage report for any of `mint.go/refresh.go/blacklist.go/password.go/locale_from_principal.go`'s exported functions below a reasonable bar — every exported function needs at least one direct test, not just indirect coverage through a handler test in another repo).
- A concurrency test: two goroutines calling `MemoryBlacklist.Add`/`IsBlacklisted` on the same jti simultaneously never panics or races (`-race` clean); same for `PostgresBlacklist` against a real (testcontainers) Postgres.
- A round-trip test: `Mint` → `Verify` → `Refresh` → `Verify` (on the new token) → logout-equivalent `Add` with `validUntil=now` → the new token's `IsBlacklisted` is immediately `true`.
</behavior>
<action>
Run `go test ./bouncer/... ./surf/... ./lagoon/... -cover -short` and `-race`, read the coverage report, and add the missing direct-unit tests for any exported symbol from 07-01 that has no dedicated test today. Add the concurrency and round-trip tests described above. Do not modify production code in this task unless a test uncovers an actual bug (if so, fix it, note it in the SUMMARY's Deviations section per the standard executor protocol, and keep the fix minimal).
</action>
<verify>
<automated>go vet ./... && go test ./bouncer/... ./surf/... ./lagoon/... -race -cover</automated>
</verify>
<acceptance_criteria>
- `go test ./bouncer/... ./surf/... ./lagoon/... -race` exits 0 with no data-race report
- A coverage report shows no Phase 7 exported function in `mint.go/refresh.go/blacklist.go/password.go/locale_from_principal.go` with zero direct test references
- The concurrent `MemoryBlacklist`/`PostgresBlacklist` Add/IsBlacklisted test passes under `-race`
</acceptance_criteria>
<done>go vet and go test -race are green across bouncer/surf/lagoon; every exported Phase 7 symbol in these packages has a direct test.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Complete fonoteka.go coverage — user plugin, fonoteka plugin, mail</name>
<files>
../fonoteka.go/plugins/golem15/user/controllers/api_controller_test.go,
../fonoteka.go/plugins/golem15/user/classes/throttle_test.go,
../fonoteka.go/plugins/golem15/user/classes/codes_test.go,
../fonoteka.go/plugins/golem15/user/classes/events_test.go,
../fonoteka.go/plugins/golem15/user/updates/user_session_test.go,
../fonoteka.go/plugins/golem15/fonoteka/controllers/api/token_api_controller_test.go,
../fonoteka.go/plugins/golem15/fonoteka/controllers/api/me_locale_controller_test.go,
../fonoteka.go/plugins/golem15/fonoteka/routes_isolation_test.go,
../fonoteka.go/plugins/golem15/fonoteka/classes/auth/api_token_manager_test.go
</files>
<read_first>
../fonoteka.go/plugins/golem15/user/controllers/api_controller.go (current, all handlers from 07-02/07-03),
../fonoteka.go/plugins/golem15/fonoteka/controllers/api/token_api_controller.go, me_locale_controller.go (07-04),
.planning/phases/07-user-plugin-and-authentication/07-VALIDATION.md (Per-Task Verification Map — `TestApiController`, `TestGetApiArray`, `TestTokenApi`, `TestMustChangePasswordLock`, the mail memory-driver assertion)
</read_first>
<behavior>
- 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).
- `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.
</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.
</action>
<verify>
<automated>go vet ./... && go test ./... -race -cover</automated>
</verify>
<acceptance_criteria>
- `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
</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>
</task>
<task type="auto">
<name>Task 3: Finalize 07-VALIDATION.md and run the full phase gate</name>
<files>.planning/phases/07-user-plugin-and-authentication/07-VALIDATION.md</files>
<read_first>
.planning/phases/07-user-plugin-and-authentication/07-VALIDATION.md (current draft — Per-Task Verification Map with `TBD` Task IDs, Wave 0 Requirements checklist, Validation Sign-Off checklist)
</read_first>
<action>
Fill in every `TBD` Task ID in the Per-Task Verification Map with the real `{plan}-{task}` id it landed in (e.g. `07-02-2`, `07-04-1`), set every `Status` cell to `✅ green` (confirmed by the full run below), check off every Wave 0 Requirements box, check off every Validation Sign-Off box, and set the frontmatter `wave_0_complete: true` and `nyquist_compliant: true`. Do not mark a row green without having actually re-run its `Automated Command` in this task.
Run the full phase gate: `go vet ./... && go test ./... -race` in `summercms.go`; `go vet ./... && go test ./... -race` in `fonoteka.go`; `summer parity:replay --manifest fonoteka.go/parity/manifest.yaml`. Record the final corpus coverage numbers (recorded/ported/pending) in this plan's SUMMARY.
</action>
<verify>
<automated>go vet ./... && go test ./... -race</automated>
</verify>
<acceptance_criteria>
- `07-VALIDATION.md`'s Per-Task Verification Map has zero `TBD` Task IDs and zero `⬜ pending` Status cells
- `07-VALIDATION.md` frontmatter reads `nyquist_compliant: true` and `wave_0_complete: true`
- `go vet ./... && go test ./... -race` exits 0 in both summercms.go and fonoteka.go
- `summer parity:replay --manifest fonoteka.go/parity/manifest.yaml` reports zero failing cases
</acceptance_criteria>
<done>07-VALIDATION.md has zero TBD/pending rows, nyquist_compliant: true; the full phase gate (both repos + parity replay) is green.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
No new trust boundaries — this plan tests behavior already gated in 07-01..07-05; its own threat surface is "a test asserts the wrong thing and gives false confidence," mitigated by tying every new test to a specific D-XX/C-XX/T-07-NN citation in its name or comment rather than writing generic smoke tests.
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|------------------|
| T-07-01 | Spoofing / Elevation of Privilege | Full-sequence test | mitigate (verification) | The Go-side login→refresh→logout→401 sequence test proves T-07-01's mitigation holds end-to-end, not just in the isolated 07-01/07-02 unit tests |
| T-07-08 | Elevation of Privilege | TestMustChangePasswordLock | mitigate (verification) | Proves the 423 lock's exempt set against a real dual-plugin boot, the definitive AUTH-04 acceptance test |
Note: `/gsd:secure-phase 7` runs after this plan and produces `07-SECURITY-REVIEW.md`; this plan's job is to hand it a fully green, fully tested baseline, not to perform the security review itself.
</threat_model>
<verification>
`go vet ./... && go test ./... -race` green in both `summercms.go` and `fonoteka.go`. `summer parity:replay` green across the full manifest (154-route fonoteka surface + 15-route user surface + tokens/me-locale). `07-VALIDATION.md` fully signed off.
</verification>
<success_criteria>
Phase 7 is fully implemented, fully tested, and ready for the security-review agent — no open Wave 0 gaps, no red tests, no TBD rows in the validation contract.
</success_criteria>
<output>
Create `.planning/phases/07-user-plugin-and-authentication/07-06-SUMMARY.md` when done
</output>