diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index f7e1534..e6a560e 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -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 diff --git a/.planning/phases/07-user-plugin-and-authentication/07-01-PLAN.md b/.planning/phases/07-user-plugin-and-authentication/07-01-PLAN.md new file mode 100644 index 0000000..2f18a58 --- /dev/null +++ b/.planning/phases/07-user-plugin-and-authentication/07-01-PLAN.md @@ -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" +--- + + +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`. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.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 + + + + +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)`. + + + + + + + Task 1: Approve promoting golang.org/x/crypto to a direct dependency + go.mod, go.sum + + .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 + + + 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. + + + go list -m -json golang.org/x/crypto + + + 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. + + Type "approved" to promote golang.org/x/crypto to a direct dependency, or name an alternative bcrypt implementation to use instead. + + - `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 + + 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. + + + + Task 2: JWT lifecycle primitives — Mint, Refresh, Blacklist, Principal extension + 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/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) + + + - 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: + + 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 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
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). + + + go vet ./... && go test ./bouncer/... -run 'TestMint|TestRefresh|TestBlacklist|TestJWTGuard|TestRegistry|TestContext' -v + + + - `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 + + 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. + + + + Task 3: Password hashing, post-auth locale override, and lagoon.Validate extensions + 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 + + 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) + + + - 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"`. + + + 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. + + + go vet ./... && go test ./bouncer/... ./surf/... ./lagoon/... -run 'TestPassword|TestNeedsRehash|TestLocaleFromPrincipal|TestValidate' -v + + + - `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 + + 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. + + + + + +## 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) | + + + + +`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`). + + + +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. + + + +Create `.planning/phases/07-user-plugin-and-authentication/07-01-SUMMARY.md` when done + diff --git a/.planning/phases/07-user-plugin-and-authentication/07-02-PLAN.md b/.planning/phases/07-user-plugin-and-authentication/07-02-PLAN.md new file mode 100644 index 0000000..894760f --- /dev/null +++ b/.planning/phases/07-user-plugin-and-authentication/07-02-PLAN.md @@ -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" +--- + + +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`. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/07-user-plugin-and-authentication/07-CONTEXT.md +@.planning/phases/07-user-plugin-and-authentication/07-RESEARCH.md +@.planning/phases/07-user-plugin-and-authentication/07-PATTERNS.md +@.planning/phases/07-user-plugin-and-authentication/07-01-SUMMARY.md + + + +```go +func bouncer.Mint(secret, sub, issuerURL string, ttl time.Duration) (token, jti string, err error) +func bouncer.Refresh(secret, tokenString string, refreshTTL time.Duration, bl BlacklistStore, grace time.Duration, issuerURL string) (string, error) +type bouncer.BlacklistStore interface { + Add(ctx context.Context, jti string, expiresAt, validUntil time.Time) error + IsBlacklisted(ctx context.Context, jti string) (bool, error) + Sweep(ctx context.Context, now time.Time) error +} +func bouncer.NewPostgresBlacklist(db *sql.DB, table string) *PostgresBlacklist +func bouncer.NewJWTGuard(secret string, users UserProvider, bl BlacklistStore, cookieNames ...string) Guard +func bouncer.HashPassword(cost int, plain string) (string, error) +func bouncer.CheckPassword(hash, plain string) bool +func bouncer.NeedsRehash(hash string, configuredCost int) bool +type bouncer.Principal struct { ID uint; MustChangePassword bool; PreferredLocale string; TokensValidAfter time.Time } +``` +`bouncer.Verify(tokenString, secret string) (sub string, err error)` is unchanged (Bearer parsing only reads `Authorization`; cookie fallback is a `NewJWTGuard` constructor option only golem15.fonoteka's `jwt.auth` registration uses in 07-04 — this plan's per-handler calls are Bearer-only, per D-09, matching `bouncer.Verify`'s existing behavior). `bouncer.VerifyClaims(tokenString, secret string) (sub string, iat, exp time.Time, jti string, err error)` (new in 07-01) is the exported claims-parsing entry point Logout/Refresh use to get `iat`/`exp`/`jti` outside the `bouncer` package. + + +From ../fonoteka.go/plugins/golem15/user/plugin.go (current): Boot registers "jwt" via `reg.Register(p.ID(), "jwt", bouncer.NewJWTGuard(secret, classes.GormUsers{App: app}))` — this call site's arity changes this plan (add `bl` and cookie names). +From ../fonoteka.go/plugins/golem15/fonoteka/plugin.go (current): `Buckets()` shows the exact `map[string]surf.Bucket` shape to copy for `user-api`. + + + + + + + Task 1: User/Throttle schema — models, appended migrations, config keys + + ../fonoteka.go/plugins/golem15/user/models/user.go, + ../fonoteka.go/plugins/golem15/user/models/throttle.go, + ../fonoteka.go/plugins/golem15/user/updates/202609220005_extend_users.go, + ../fonoteka.go/plugins/golem15/user/updates/202609220006_create_user_throttle.go, + ../fonoteka.go/plugins/golem15/user/updates/202609220007_create_jwt_blacklist.go, + ../fonoteka.go/plugins/golem15/user/updates/user_session_test.go, + ../fonoteka.go/plugins/golem15/user/config/config.yaml, + ../fonoteka.go/config/golem15.user.yaml + + + ../fonoteka.go/plugins/golem15/user/models/user.go (current 4-column stub), ../fonoteka.go/plugins/golem15/user/models/registry.go, + ../fonoteka.go/plugins/golem15/user/updates/00_base.go, ../fonoteka.go/plugins/golem15/user/updates/10_organisations.go, ../fonoteka.go/plugins/golem15/user/updates/postgres_test.go, ../fonoteka.go/plugins/golem15/user/updates/organisations_test.go, + ../fonoteka.go/plugins/golem15/fonoteka/updates/10_album_slice.go (execStmts ALTER pattern), + ../fonoteka.go/plugins/golem15/fonoteka/models/api_token.go (Fillable/Hidden shape), + ../fonoteka.go/plugins/golem15/fonoteka/models/artist.go (Fillable/Rules/BeforeValidate convention to copy), + summercms.go/compass/config_test.go (TestDottedPluginNamespaceAndTypedSection — the app-level `config/golem15.user.yaml` override precedence this plan relies on), + /media/nvme/dev/golem15/fonoteka/plugins/golem15/user/models/User.php (lines 1-158: $fillable/$hidden/$casts already read this session), + .planning/phases/07-user-plugin-and-authentication/07-CONTEXT.md (D-06, D-10, D-15, D-16, D-19, D-20, D-21) + + + - Test (migration): `TestExtendUsersMigration` runs `All()` against a fresh Postgres, asserts every new column exists (`name, surname, is_activated, activated_at, activation_code, activation_code_issued_at, reset_password_code, reset_password_code_issued_at, has_self_set_password, marketing_consent, is_onboarded, organisation_id, organisation_role, preferred_locale, tokens_valid_after, created_ip_address, last_ip_address`) and that `has_self_set_password` defaults `true` and `is_activated`/`marketing_consent`/`is_onboarded` default `false` for a freshly inserted row. + - Test (migration): `TestCreateUserThrottleMigration` asserts table `user_throttle` exists with columns `id, user_id, ip_address, attempts, is_suspended, suspended_at, is_banned, created_at, updated_at`. + - Test (migration): `TestCreateJwtBlacklistMigration` asserts table `jwt_blacklist` exists with columns `jti (primary key), expires_at, valid_until`. + - Test (migration): rollback of each new migration (in reverse ID order) leaves `users`/`user_throttle`/`jwt_blacklist` in their pre-migration shape (no orphaned columns/tables). + + + Extend `models/user.go`'s `User` struct with the fields listed in the migration behavior above (pointer types for nullable columns, matching the `Artist`/`ApiToken` pointer convention already in this codebase), keeping `ID/Email/Password/MustChangePassword` unchanged. Add `Fillable() []string` returning `{"name","surname","email","password","created_ip_address","last_ip_address","is_onboarded","preferred_locale","marketing_consent","organisation_id","organisation_role","must_change_password"}` (deliberately narrower than PHP's list — `username`/`pin`/`login`/GDPR-consent columns are out of Phase 7 scope: no ported handler reads or writes them). `Hidden() []string` returns `{"password","reset_password_code","activation_code"}`. `Rules() map[string]string` returns the register()-time rules only: `{"email": "required|between:6,255|email|unique:users", "password": "required|between:8,255|confirmed"}` (Laravel's `required:create`/`required_with` context modifiers have no `lagoon.Validate` equivalent and are dropped; `confirmed` alone already enforces the password/password_confirmation pairing). Keep the existing `TableName()`/`init(){ Register(User{}) }`. + + Create `models/throttle.go`: `Throttle` struct (`ID uint`, `UserID uint`, `IPAddress *string`, `Attempts int`, `IsSuspended bool`, `SuspendedAt *time.Time`, `IsBanned bool`, `CreatedAt/UpdatedAt time.Time`), `TableName() string { return "user_throttle" }`, `init(){ Register(Throttle{}) }`. + + Create three appended migration files in `updates/`, each a new `[]*gormigrate.Migration` var + its own `init(){ Register(...) }` (never touch `00_base.go`/`10_organisations.go`, P5 D-03): + - `202609220005_extend_users.go`: one migration, `ALTER TABLE users ADD COLUMN ...` for every new column via the `execStmts` helper convention from `10_album_slice.go` (copy that helper if not already package-visible), with `organisation_id` as `INTEGER REFERENCES golem15_user_organisations(id)`, booleans `NOT NULL DEFAULT`, everything else nullable. Rollback drops columns in reverse order. + - `202609220006_create_user_throttle.go`: `CREATE TABLE user_throttle (id SERIAL PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id), ip_address TEXT, attempts INTEGER NOT NULL DEFAULT 0, is_suspended BOOLEAN NOT NULL DEFAULT FALSE, suspended_at TIMESTAMPTZ, is_banned BOOLEAN NOT NULL DEFAULT FALSE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW())` plus a plain (non-unique) index on `(user_id, ip_address)` — Pitfall 5's NULL-ip fallback row rules out a unique constraint. Rollback drops the table. + - `202609220007_create_jwt_blacklist.go`: `CREATE TABLE jwt_blacklist (jti TEXT PRIMARY KEY, expires_at TIMESTAMPTZ NOT NULL, valid_until TIMESTAMPTZ NOT NULL)`. Rollback drops the table. + + Extend `../fonoteka.go/plugins/golem15/user/config/config.yaml` (library defaults, matching PHP's `config()` fallback values read this session) with `jwt: {ttl: 60, refresh_ttl: 20160, blacklist_grace: 0, leeway: 0}` (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`). + + + go vet ./... && go test ./plugins/golem15/user/updates/... -run 'TestExtendUsersMigration|TestCreateUserThrottleMigration|TestCreateJwtBlacklistMigration' + + + - `gdb.Migrator().HasColumn("users", col)` is true for every column named in this task's behavior list, after `All()` migrates up + - A freshly inserted `users` row has `has_self_set_password = true` and `is_activated = false` by column default + - `user_throttle` and `jwt_blacklist` tables exist with exactly the columns specified + - Rolling back all three new migrations (reverse ID order) leaves `users` with no orphaned columns and drops both new tables + - `../fonoteka.go/config/golem15.user.yaml` exists and contains `jwt.ttl: 1440`, `jwt.refresh_ttl: 43200`, `jwt.blacklist_grace: 10` + + All three migrations run up and down cleanly against a real Postgres; models.User/Throttle compile with the new columns; config.yaml and the new app-level override file carry every D-10/D-15/D-16/D-19 default and Płytarium value. + + + + Task 2: Core session loop — throttle port, login/logout/fetch/refresh + + ../fonoteka.go/plugins/golem15/user/classes/throttle.go, + ../fonoteka.go/plugins/golem15/user/classes/throttle_test.go, + ../fonoteka.go/plugins/golem15/user/classes/user_lookup.go, + ../fonoteka.go/plugins/golem15/user/controllers/api_controller.go, + ../fonoteka.go/plugins/golem15/user/controllers/api_controller_test.go, + ../fonoteka.go/plugins/golem15/user/routes.go, + ../fonoteka.go/plugins/golem15/user/plugin.go + + + ../fonoteka.go/plugins/golem15/fonoteka/controllers/genre_controller.go (handler-factory / writeJSON / write401 idioms to copy), + ../fonoteka.go/plugins/golem15/fonoteka/classes/auth/token_guard.go (remoteIP helper to copy verbatim), + ../fonoteka.go/plugins/golem15/fonoteka/plugin.go (Buckets() shape to copy for user-api), + summercms.go/bouncer/jwt.go, summercms.go/bouncer/mint.go, summercms.go/bouncer/refresh.go, summercms.go/bouncer/blacklist.go (all from 07-01), + summercms.go/wire/response.go, + /media/nvme/dev/golem15/fonoteka/plugins/golem15/user/controllers/ApiController.php (lines 42-174, 1412-1437: login/logout/fetch/refresh/authorize — already read this session), + /media/nvme/dev/golem15/fonoteka/vendor/winter/storm/src/Auth/Manager.php (lines 318-458: findThrottleByLogin/validateInternal), + .planning/phases/07-user-plugin-and-authentication/07-RESEARCH.md (Pitfall 5, the CheckAndRecordLogin code example) + + + - Test (throttle): 5 failed `CheckAndRecordLogin(ctx, db, user, ip, false)` calls for the same `(user, ip)` succeed (no error, `attempts` increments 1..5); the 6th returns a non-nil error and further attempts keep erroring until `suspended_at + 15m` has passed. + - Test (throttle): a row with `is_banned = true` always errors regardless of attempt count. + - Test (throttle): a successful login (`ok=true`) resets `attempts` to 0 and clears `is_suspended`. + - Test (throttle): an unknown login (no matching user row) never creates or touches a throttle row (Pitfall 5 — throttle keys off an existing user). + - Test (login handler, httptest): valid email+password returns 200 `{"token":"...","user":{...}}` with a token that decodes to `sub=`, `prv=a867434cbc213adfbe78a02bed7082a6bd99c883`, `iss` equal to the request's own full URL. + - Test (login handler): wrong password, throttled account, and unknown email all return the IDENTICAL body `{"error":true,"message":"Invalid email or password"}`, status 401 (Pitfall 5 / the ApiController.php:81-88 generic catch — not per-cause bodies). + - Test (logout handler): a valid bearer token returns 200 `{"message":"Logged out"}` and the SAME token immediately fails a subsequent `fetch` call with 401 `{"error":true,"message":"Unauthorized"}` (forever-blacklist). + - Test (fetch handler): valid bearer returns 200 `{"user":{...}}`; missing/invalid bearer returns 401 `{"error":true,"message":"Unauthorized"}`. + - Test (refresh handler): no token present returns 401 `{"error":"Token not found"}` (string `error`, no `message`/`msg` key — distinct envelope from login/logout/fetch); an expired-but-within-`refresh_ttl` token returns 200 `{"token":"..."}` with a NEW jti, and the OLD token then fails IsBlacklisted-gated auth; a token past its refresh window returns 401 `{"error":"Could not refresh token","msg":"..."}`. + - Test (group wiring): `/_user/api/v1` carries exactly `throttle:user-api` at the group level and no `jwt.auth`/`inv.must-change-password` (D-01); a boot-smoke test asserts this over the real route table (mirror the Phase 6 `TestAllRouteGroupsBoot` boot-probe idiom if the group is otherwise empty of a distinguishing assertion). + + + 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)`. + + + go vet ./... && go test ./plugins/golem15/user/... -run 'TestCheckAndRecordLogin|TestLogin|TestLogout|TestFetch|TestRefresh' -short + + + - Valid credentials return 200 `{"token":"...","user":{...}}`; the token decodes to `sub=` and `prv=a867434cbc213adfbe78a02bed7082a6bd99c883` + - Wrong password, a throttled account, and an unknown email all return the byte-identical body `{"error":true,"message":"Invalid email or password"}`, status 401 + - `POST logout` followed immediately by `GET fetch` with the SAME token returns 401 `{"error":true,"message":"Unauthorized"}` on the fetch call + - `POST refresh` with no token returns 401 `{"error":"Token not found"}` (no `message`/`msg` key); a past-refresh-window token returns 401 `{"error":"Could not refresh token","msg":"..."}` + - The 6th failed login for the same `(user,ip)` within 15 minutes is rejected by `CheckAndRecordLogin` before any password comparison runs + - `surf.RouteInfo` for every `/_user/api/v1` route lists `Middleware == ["throttle:user-api"]` (no `jwt.auth`, no `inv.must-change-password`) + + 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. + + + + Task 3: Registration slice — register handler, getApiArray event, fonoteka listener + + ../fonoteka.go/plugins/golem15/user/classes/events.go, + ../fonoteka.go/plugins/golem15/user/classes/events_test.go, + ../fonoteka.go/plugins/golem15/user/controllers/api_controller.go, + ../fonoteka.go/plugins/golem15/user/controllers/api_controller_test.go, + ../fonoteka.go/plugins/golem15/user/routes.go, + ../fonoteka.go/plugins/golem15/user/plugin.go, + ../fonoteka.go/plugins/golem15/fonoteka/plugin.go + + + summercms.go/festival/bus.go, summercms.go/examples/hello/plugins/greeter/plugin.go (HelloEvent — Collectable/Handleable idiom), + ../fonoteka.go/plugins/golem15/fonoteka/Plugin.php lines 224-253 (already read this session — the exact flat-merge listener shape and comment about halt=false/array_merge order), + /media/nvme/dev/golem15/fonoteka/plugins/golem15/user/controllers/ApiController.php lines 251-346 (register — already read this session, including the ApplicationException/SafeExceptionResponse interaction), + /media/nvme/dev/golem15/fonoteka/plugins/golem15/user/Plugin.php lines 316-364 (getApiArray base payload — already read this session), + /media/nvme/dev/golem15/fonoteka/.../SafeExceptionResponse.php (already read this session), + .planning/phases/07-user-plugin-and-authentication/07-CONTEXT.md (D-02, A4) + + + - Test (events): firing `GetApiArrayEvent{User: u}` through `app.Events.Collect` with a fonoteka-style listener registered returns a map containing the listener's keys merged onto the base map; a second listener registered at a later priority overwrites a colliding key (later wins, matching PHP `array_merge` order per RESEARCH Pattern 2). + - Test (events, import direction): a `go list -deps` (or an equivalent static check) over `plugins/golem15/user/...` contains no `plugins/golem15/fonoteka` import (AUTH-02's "without golem15.user importing golem15.fonoteka"). + - Test (register handler): valid registration data with `activate_mode=auto` (the config default) returns 200 `{"token":"...","user":{...}}`, and the new user can immediately `fetch` with that token. + - Test (register handler): a request missing `email` returns 422 `{"error":"","errors":{"email":[...]}}`. + - Test (register handler): `allow_registration=false` (test override) returns 500 `{"error":"Internal server error"}` — NOT the literal "Registrations are currently disabled." text — reproducing `SafeExceptionResponse`'s production-mode (app.debug=false) degradation of any non-Validation/Http/Authentication exception, confirmed by reading the trait this session; the literal text only surfaces when `app.debug=true` (also test this branch). + - Test (getApiArray via fonoteka listener): registering `golem15.user` + `golem15.fonoteka` together and firing `GetApiArrayEvent` for a user with `OrganisationID`/`OrganisationRole`/`MustChangePassword`/`PreferredLocale` set produces a payload containing exactly those four extra keys with those values (AUTH-02's acceptance shape). + + + 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": ""}`; 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": , "errors": }`,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": }`,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. + + + go vet ./... && go test ./plugins/golem15/... -run 'TestGetApiArray|TestRegister|TestRegisterEvent' -short + + + - `activate_mode=auto` registration returns 200 `{"token":"...","user":{...}}` and the returned token authenticates an immediate `fetch` + - `allow_registration=false` returns 500 `{"error":"Internal server error"}` when `app.debug=false`, and the literal `"Registrations are currently disabled."` text only when `app.debug=true` + - A `GetApiArrayEvent` collected with a `golem15.fonoteka`-style listener registered contains `organisation_id`, `organisation_role`, `must_change_password`, `preferred_locale` as the ONLY extra keys beyond the base payload + - `go list -deps ./plugins/golem15/user/...` contains no `plugins/golem15/fonoteka` path segment + - A missing `email` field in the register request returns 422 with `errors.email` present + + 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. + + + + + +## 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 | + + + + +`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. + + + +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. + + + +Create `.planning/phases/07-user-plugin-and-authentication/07-02-SUMMARY.md` when done + diff --git a/.planning/phases/07-user-plugin-and-authentication/07-03-PLAN.md b/.planning/phases/07-user-plugin-and-authentication/07-03-PLAN.md new file mode 100644 index 0000000..f124a22 --- /dev/null +++ b/.planning/phases/07-user-plugin-and-authentication/07-03-PLAN.md @@ -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 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)" +--- + + +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. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/07-user-plugin-and-authentication/07-CONTEXT.md +@.planning/phases/07-user-plugin-and-authentication/07-RESEARCH.md +@.planning/phases/07-user-plugin-and-authentication/07-PATTERNS.md +@.planning/phases/07-user-plugin-and-authentication/07-02-SUMMARY.md + + + +```go +func bouncer.VerifyClaims(tokenString, secret string) (sub string, iat, exp time.Time, jti string, err error) +func bouncer.HashPassword(cost int, plain string) (string, error) +func bouncer.CheckPassword(hash, plain string) bool +func bouncer.NeedsRehash(hash string, configuredCost int) bool +func apiArray(ctx context.Context, app *backpack.App, user *models.User) (map[string]any, error) // 07-02 Task 3, same package +``` +From `summercms.go/lagoon/attach/file.go` and `thumb.go` (read in full before the avatar task — no HTTP handler analog exists yet, this plan writes the first one): +```go +type attach.Owner interface{ MorphName() string } +type attach.File struct { ID uint; DiskName, FileName string; FileSize int64; ContentType string; Field, AttachmentID, AttachmentType string; IsPublic *bool; SortOrder int; CreatedAt, UpdatedAt time.Time } +func (f *attach.File) Thumb(ctx context.Context, bucket *blob.Bucket, w, h int, mode string) (string, error) +func attach.DeleteForOwner(tx *gorm.DB, owner attach.Owner, ownerID string, afterCommit func([]string) error) error +func attach.BlobKey(diskName string) string +func attach.PartitionDirectory(diskName string) string +``` +`models.User` has no `MorphName()` yet — this plan adds `func (User) MorphName() string { return "Golem15\\User\\Models\\User" }` (the PHP class string, per the `Owner` interface contract other Phase 5 models already implement) so `attach.File` rows and their `blobKeysFor` cleanup match cutover-imported rows. + + + + + + + Task 1: Reset/activation codes and their four handlers + + ../fonoteka.go/plugins/golem15/user/classes/codes.go, + ../fonoteka.go/plugins/golem15/user/classes/codes_test.go, + ../fonoteka.go/plugins/golem15/user/controllers/api_controller.go, + ../fonoteka.go/plugins/golem15/user/controllers/api_controller_test.go, + ../fonoteka.go/plugins/golem15/user/routes.go, + ../fonoteka.go/plugins/golem15/user/config/config.yaml + + + ../fonoteka.go/plugins/golem15/fonoteka/classes/auth/token_guard.go (sha256-hex hashing convention; this task's compare is constant-time string compare, not hash compare — codes are short and never hashed at rest, matching PHP's plain-text `reset_password_code`/`activation_code` columns), + ../fonoteka.go/plugins/golem15/user/models/user.go (from 07-02 — ResetPasswordCode/ResetPasswordCodeIssuedAt/ActivationCode/ActivationCodeIssuedAt fields), + /media/nvme/dev/golem15/fonoteka/plugins/golem15/user/controllers/ApiController.php lines 180-245, 638-715 (activate/activateByCode/forgotPassword/resetPassword — already read this session), + /media/nvme/dev/golem15/fonoteka/vendor/winter/storm/src/Auth/Models/User.php lines 209-292 (activation/reset code semantics, no expiry — already read this session), + .planning/phases/07-user-plugin-and-authentication/07-CONTEXT.md (D-15, D-18, D-20) + + + - Test (codes): `IssueResetCode`/`IssueActivationCode` store a non-empty code plus an issued-at timestamp equal to `time.Now()` (within a small tolerance) and return the raw code. + - Test (codes): `VerifyResetCode(ctx, db, id, code)` succeeds for the exact issued code within the configured TTL (`reset_ttl_minutes`, default 60), fails for a wrong code, and fails once `issued_at + TTL` has passed. + - Test (codes): a row whose `reset_password_code_issued_at` is `NULL` (simulating a PHP-issued, pre-cutover code) is treated as issued "now" and gets one full TTL (D-15's null-issued-at rule) — insert such a row directly via SQL in the test, bypassing the Go issuing path, to prove this. + - Test (codes): the compare uses `crypto/subtle.ConstantTimeCompare` (assert via a code-reading check, or a timing-insensitive behavioral test that a code differing only in its last byte fails identically to one differing in its first byte — do not assert on timing directly, that is flaky; assert on the constant-time helper being called via a small refactor seam if easier, e.g. a package-level `compareFunc` variable swapped in the test). + - Test (forgot-password handler): both an existing and a nonexistent email return the IDENTICAL `{"message":"If that email exists, a reset link has been sent."}`,200; a malformed email returns 422 `{"error":"...","errors":{"email":[...]}}`. + - Test (reset-password handler): a valid `"{id}!{code}"` plus a confirmed new password returns 200 `{"message":"Password has been reset"}`, and the user can then log in with the new password but NOT the old one; a malformed code (no `!`) returns 422 `{"error":"Invalid reset code"}`; a wrong or expired code returns 422 `{"error":"Invalid or expired reset code"}`. + - Test (activate handler, authenticated): calling it with a WRONG code still returns 200 `{"user":{...}}` with `is_activated` unchanged (PHP's `attemptActivation()` call has no `if` guard — ApiController.php:180-196, confirmed this session; do not add a check Go-side that PHP doesn't have). + - Test (activate-by-code handler, public): a valid `"{id}!{code}"` returns 200 `{"message":"Account activated","token":"...","user":{...}}` with `is_activated=true`; an invalid one returns 422 `{"error":"This activation link is invalid or has expired"}`; a missing `code` field returns 422 `{"error":"...","errors":{"code":[...]}}`. + + + Extend `../fonoteka.go/plugins/golem15/user/config/config.yaml`'s `activation` section (already present from 07-02) is sufficient — do not duplicate `reset_ttl_minutes`/`activation_ttl_hours` under a new key. + + Create `classes/codes.go`: `func newCode() (string, error)` — 32 random bytes via `crypto/rand`, hex-encoded (64 chars; PHP's own generation algorithm need not be reproduced bit-for-bit, only the stored plain-text format and the `"{id}!{code}"` link format matter for cutover compatibility, D-15). `func IssueResetCode(ctx, db *gorm.DB, user *models.User) (code string, err error)` and `IssueActivationCode(...)` — generate, `UpdateColumns` the code + its issued-at column to `time.Now()`, return the raw code. `func VerifyResetCode(ctx, db, userID uint, code string) (*models.User, bool)` and `VerifyActivationCode(...)`: load the user by id (do not filter `deleted_at` for activation — a trashed user restoring via a matching activation code is a real PHP path, `attemptActivation`'s trashed branch), compute the TTL cutoff (`issuedAt` = the column's value, or `time.Now()` when the column is `NULL` — D-15's cutover rule — compared against `time.Now()`), reject if past cutoff, `subtle.ConstantTimeCompare([]byte(code), []byte(stored)) == 1` (pad/compare lengths safely — `ConstantTimeCompare` requires equal-length slices; a length mismatch is simply "not equal", checked before calling it, still without a data-dependent branch on the code's actual bytes) . On success for reset: also flip `has_self_set_password=true` (D-15/k7ut351s-equivalent parity, mirrors PHP's `attemptResetPassword`) and clear the code; caller sets the new password. On success for activation: set `is_activated=true`, `activated_at=now`, clear `activation_code`; if the user was soft-deleted, `db.Unscoped().Model(user).Update("deleted_at", nil)` first (the trashed branch). + + In `controllers/api_controller.go`, add: + - `ForgotPassword(app)`: validate `email` (`required|email|between:6,255`) → 422 on failure. Look up by email; if found, `IssueResetCode`, build the reset link (`golem15.user.reset_url_base` config, default `/reset-password`, `?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": }`,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":}`,200. + + Add the four routes to the existing `/_user/api/v1` group in `routes.go`: `g.Post("/forgot-password", controllers.ForgotPassword(p.app))`, `g.Post("/reset-password", controllers.ResetPassword(p.app))`, `g.Post("/activate", controllers.Activate(p.app))`, `g.Post("/activate-by-code", controllers.ActivateByCode(p.app))`. + + + go vet ./... && go test ./plugins/golem15/user/... -run 'TestCodes|TestForgotPassword|TestResetPassword|TestActivate' -short + + + - `ForgotPassword` returns the byte-identical 200 body for both an existing and a nonexistent email + - `ResetPassword` with a malformed code (no `!`) returns 422 `{"error":"Invalid reset code"}`; a wrong/expired code returns 422 `{"error":"Invalid or expired reset code"}`; success returns 200 `{"message":"Password has been reset"}` and the user can log in with the new password + - `Activate` (authenticated) returns 200 `{"user":{...}}` even when the supplied code is wrong, with `is_activated` unchanged — no `if` guard on the boolean result + - `ActivateByCode` on a valid `"{id}!{code}"` returns 200 with a fresh token and `is_activated:true` + - A row with `reset_password_code_issued_at = NULL` inserted directly via SQL is treated as issued now and accepts its code within one full TTL + - `go test ./plugins/golem15/user/... -run 'TestCodes|TestForgotPassword|TestResetPassword|TestActivate'` exits 0 + + All four handlers reproduce their PHP status/body pairs including the asymmetric authenticated-activate no-op-on-failure behavior; codes compare in constant time and honor the TTL and null-issued-at cutover rule. + + + + Task 2: Profile update, change-password with D-20 exemption, marketing consent + + ../fonoteka.go/plugins/golem15/user/controllers/api_controller.go, + ../fonoteka.go/plugins/golem15/user/controllers/api_controller_test.go, + ../fonoteka.go/plugins/golem15/user/routes.go + + + ../fonoteka.go/plugins/golem15/user/controllers/api_controller.go (Login/Logout from 07-02 — the per-handler-auth idiom to copy verbatim for Update/ChangePassword/MarketingConsent), + summercms.go/bouncer/jwt.go and mint.go (VerifyClaims, Mint — for the D-20 cutover math), + /media/nvme/dev/golem15/fonoteka/plugins/golem15/user/controllers/ApiController.php lines 364-515, 595-626 (update/changePassword/updateMarketingConsent — already read this session), + .planning/phases/07-user-plugin-and-authentication/07-CONTEXT.md (D-20, its resolved Open Question 1) + + + - Test (update): valid `name/surname/email` returns 200 `{"message":"Profile updated","user":{...}}` with the new values reflected; re-saving the CALLER's own unchanged email does not trip the uniqueness check; a duplicate OTHER user's email returns 422; missing `name` returns 422 with `{"error":"...","errors":{"name":[...]}}`. + - Test (change-password, self-set path): correct `current_password` + a `password`/`password_confirmation` pair different from the current one returns 200 `{"message":"Password changed","user":{...}}`; the OLD token used to make this exact call still authenticates a subsequent `fetch` (D-20 exemption); a DIFFERENT older token for the same user (minted before this call) now gets 401 on `fetch`. + - Test (change-password): wrong `current_password` returns 422 `{"error":"Invalid email or password","errors":{"current_password":["Invalid email or password"]}}` (reusing the login-failure message text per ApiController.php:451, confirmed this session). + - Test (change-password): `password` equal to `current_password` (the `different:current_password` rule) returns 422. + - Test (change-password, `has_self_set_password=false` row): the handler 500s with an opaque body rather than silently treating the row as self-set (D-03's "documented as deferred, not silently treated as self-set" — Open Question 3's confirmed low-risk resolution: no Go-created row can reach this state today, so a 500 fail-loud guard is correct and should never fire in practice). + - Test (marketing-consent): `{"marketing_consent": true}` returns 200 `{"message":"Marketing consent updated","user":{...}}` with `marketing_consent: true`; `false` clears it; a non-boolean value returns 422. + + + Add `Update(app)`: per-handler Bearer-only auth. Validate `name` (`required|between:2,255`), `surname` (`required|between:2,255`), `email` (`required|between:6,255|email|unique:users` — `lagoon.Validate`'s `uniqueOK` already excludes the caller's own row via `modelUintID`, matching PHP's `unique:users,email,{id}`). On failure, `{"error": , "errors": {...}}`,422. On success, set the three fields directly (never mass-assign the whole request body — matches PHP's explicit-field-write comment) and save; `{"message":"Profile updated","user": }`,200. + + Add `ChangePassword(app)`: per-handler auth via `bouncer.VerifyClaims` (not the plain guard — this handler needs the presenting token's `iat` for the D-20 exemption). On auth failure, `{"error":true,"message":"Unauthorized"}`,401. Compute `hasSelfSetPassword := user.HasSelfSetPassword` — if `false`, return an opaque `500` (`wire.WriteOpaque500`), log server-side, per D-03/Open Question 3 (this branch is provably unreachable for any Go-created row but must fail loud, not silently succeed, if a cutover-migrated row ever hits it). Validate: `current_password` `required`, `password` `required|between:,255|confirmed|different:current_password` (config `golem15.user.password.min_length`, default 8). On validation failure, `{"error": , "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": }`,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": }`,200. + + Add the three routes to the `/_user/api/v1` group: `g.Post("/update", controllers.Update(p.app))`, `g.Post("/change-password", controllers.ChangePassword(p.app))`, `g.Post("/marketing-consent", controllers.MarketingConsent(p.app))`. + + + go vet ./... && go test ./plugins/golem15/user/... -run 'TestUpdate|TestChangePassword|TestMarketingConsent' -short + + + - `Update` with a duplicate OTHER user's email returns 422; re-saving the caller's own unchanged email does not + - `ChangePassword` with the correct current password and a new, different, confirmed password returns 200 `{"message":"Password changed","user":{...}}` with no `token` key + - The presenting token used to CALL `ChangePassword` still authenticates a subsequent `fetch`; a token minted for the same user BEFORE that call gets 401 on `fetch` afterward + - Wrong `current_password` returns 422 `{"error":"Invalid email or password","errors":{"current_password":["Invalid email or password"]}}` + - `MarketingConsent` with `{"marketing_consent":true}` returns 200 with `user.marketing_consent == true` + + change-password's D-20 exemption is proven by a passing test (presenting token survives, a prior token does not); update/marketing-consent match their PHP bodies exactly. + + + + Task 3: Avatar upload/remove, mail templates, require-password-change command + + ../fonoteka.go/plugins/golem15/user/controllers/api_controller.go, + ../fonoteka.go/plugins/golem15/user/controllers/api_controller_test.go, + ../fonoteka.go/plugins/golem15/user/routes.go, + ../fonoteka.go/plugins/golem15/user/models/user.go, + ../fonoteka.go/plugins/golem15/user/console/require_password_change.go, + ../fonoteka.go/plugins/golem15/user/console/require_password_change_test.go, + ../fonoteka.go/plugins/golem15/user/views/mail/activate.htm, + ../fonoteka.go/plugins/golem15/user/views/mail/activate-en.htm, + ../fonoteka.go/plugins/golem15/user/views/mail/restore.htm, + ../fonoteka.go/plugins/golem15/user/views/mail/restore-en.htm, + ../fonoteka.go/plugins/golem15/user/views/mail/reactivate.htm, + ../fonoteka.go/plugins/golem15/user/views/mail/reactivate-en.htm, + ../fonoteka.go/plugins/golem15/user/views/mail/layouts/user.htm, + ../fonoteka.go/plugins/golem15/user/plugin.go + + + summercms.go/lagoon/attach/file.go, summercms.go/lagoon/attach/thumb.go, summercms.go/lagoon/attach/bucket.go (Owner, File, Thumb, OpenBucket — no HTTP handler analog exists, this is net new per PATTERNS.md), + summercms.go/examples/hello/plugins/base/plugin.go and views/mail/{hello.htm,hello-en.htm} and views/mail/layouts/hello.htm (the exact template file format: INI header, `==` separators, markdown body — read the actual .htm files, not just the plugin.go, before authoring new ones), + summercms.go/postcard/mailer.go, postcard/templates.go (Catalog.Register, Message, Mailer.Send), + summercms.go/bonfire/command.go (Command, Arg, Input.Argument), + summercms.go/lagoon/commands.go (a command taking a parameter, for the Run-closure shape), + /media/nvme/dev/golem15/fonoteka/plugins/golem15/user/controllers/ApiController.php lines 517-584 (updateAvatar/removeAvatar — already read this session), + .planning/phases/07-user-plugin-and-authentication/07-CONTEXT.md (D-04, D-21, C-05) + + + - Test (avatar upload): a multipart POST with a valid small JPEG under `avatar` returns 200 `{"message":"Avatar updated","user":{...,"has_avatar":true,"avatar_url":"<128 thumb URL>"}}`; a `.svg` (or any non-`jpeg,jpg,png,webp,gif` sniffed content type) returns 422 `{"error":"...","errors":{"avatar":[...]}}`; an oversized file (>4000 KB) returns 422 with an avatar-size error; a request with no `avatar` field returns 422. + - Test (avatar remove): removing an existing avatar returns 200 `{"message":"Avatar removed","user":{...,"has_avatar":false}}` and the underlying `system_files` row plus its blob are gone; removing when none exists returns 422 `{"error":"Your account has no display picture to remove.","errors":{"avatar":["Your account has no display picture to remove."]}}`. + - Test (mail): `postcard`'s `memory` driver captures a `mail.activate` send with `Vars["link"]` containing the generated activation code, when `Register`'s `user`-activation-mode branch runs (wire this test through the shared `postcard.Mailer` the plugin now publishes/resolves, matching the Phase 4 `memory`-driver assertion pattern). + - Test (console command): `user:require-password-change alice@example.com` sets `must_change_password=true` for that user and prints a success line; an unknown email returns a command error, not a panic; the command is discoverable via `pact.HasCommands`. + + + Add `func (User) MorphName() string { return "Golem15\\User\\Models\\User" }` to `models/user.go` (the `attach.Owner` interface, PHP class string verbatim so cutover-imported `system_files.attachment_type` rows keep matching, mirroring how Phase 5 models already implement this). + + In `api_controller.go`, add `UploadAvatar(app)`: per-handler Bearer-only auth. `r.ParseMultipartForm(10 << 20)`; `file, header, err := r.FormFile("avatar")`; missing/parse-error → `{"error":"...","errors":{"avatar":["The avatar field is required."]}}`,422. Read the first 512 bytes for `http.DetectContentType` (do not trust the client-supplied filename extension or `Content-Type` header), map the sniffed MIME to an extension in `{jpeg,jpg,png,webp,gif}` (jpeg/jpg both map from `image/jpeg`); a type outside that set → 422 `{"error":"...","errors":{"avatar":["The avatar must be a file of type: jpeg, jpg, png, webp, gif."]}}`. `header.Size > 4_096_000` (4000 KB) → 422 `{"error":"...","errors":{"avatar":["The avatar must not be greater than 4000 kilobytes."]}}`. On success: remove any existing avatar first (reuse the remove logic below, ignoring "none exists"), write the new file's bytes to the storage bucket at `attach.BlobKey(diskName)` (a generated disk name, e.g. hex-random + detected extension), insert an `attach.File{DiskName: ..., FileName: header.Filename, FileSize: header.Size, ContentType: detectedMIME, Field: "avatar", AttachmentID: strconv.FormatUint(uint64(user.ID),10), AttachmentType: user.MorphName(), SortOrder: 0}` row, call `f.Thumb(ctx, bucket, 128, 128, "auto")` for the `avatar_url` payload value. `{"message":"Avatar updated","user": }`,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": }`,200. + + Add the two routes: `g.Post("/avatar", controllers.UploadAvatar(p.app))`, `g.Post("/avatar/remove", controllers.RemoveAvatar(p.app))`. + + Author the three mail templates plus layout, copying the exact `.htm` file format from `examples/hello/plugins/base/views/mail/{hello,hello-en}.htm` and `views/mail/layouts/hello.htm` (INI header with `subject`/`layout` keys, `==` separator, markdown body with `{{.Name}}`/`{{.Link}}`/`{{.Code}}` placeholders — `postcard`'s `execHTML`/`execText` use Go's `html/template`/`text/template` syntax, not PHP's `{name}`). `activate`/`activate-en`: subject "Activate your account" / "Activate your account", body links to the activation URL. `restore`/`restore-en`: subject "Reset your password", body links to the reset URL. `reactivate`/`reactivate-en`: subject "Welcome back", informs the user their account was reactivated on login (D-17). `layouts/user.htm`: a plain wrapper mirroring `layouts/hello.htm`'s three-section format exactly. Mail content is NOT parity-critical (D-18 — asserted only through the `memory` driver in tests, never byte-diffed against PHP), so reasonable English copy is sufficient; Polish (`pl`) is the default locale content, `-en` is the English sibling per C-05's caller-picks-suffix convention. + + Extend `plugin.go`: add `pact.HasMailTemplates` conformance (`MailTemplatesFS() fs.FS` via `//go:embed views/mail`, `MailTemplates() []string` listing all six dotted names, `MailLayouts() map[string]string{"user": "golem15.user::mail.layouts.user"}`), matching `examples/hello/plugins/base/plugin.go`'s shape exactly. Wire `Register(app)` or `Boot(app)` to call `postcard.Activate`-published catalog's `Register` (check whether `postcard.Activate` already runs at the kernel level before plugin `Boot` per its doc comment — if so, this plugin only needs to declare the capability interfaces, not call `Register` itself; confirm against `postcard/mailer.go`'s `Activate` doc comment and an existing plugin that already ships mail templates, e.g. `golem15.hello`, before writing any manual `Register` call). + + Create `console/require_password_change.go`: a `pact.HasCommands` implementation (or extend `plugin.go`'s existing `Commands()` if a scaffolded stub already exists — none does yet, this is genuinely new) registering `bonfire.Command{Name: "user:require-password-change", Description: "Force a user to change their password on next login (D-21)", Args: []bonfire.Arg{{Name: "email", Required: true}}, Run: func(ctx, in, out) error { ... }}` per the `migrate:rollback`/positional-arg shape in `bonfire/command.go` and `lagoon/commands.go`. `Run` looks up the user by email, 404-equivalent command error if not found, sets `must_change_password=true`, `out.Success(...)`. Wire `func (p *Plugin) Commands() []bonfire.Command` on the plugin and declare `var _ pact.HasCommands = (*Plugin)(nil)`. + + + go vet ./... && go test ./plugins/golem15/user/... -run 'TestUploadAvatar|TestRemoveAvatar|TestMail|TestRequirePasswordChange' -short + + + - A valid small JPEG upload returns 200 with `user.has_avatar == true` and a non-empty `avatar_url` + - A `.svg` (or any content type outside jpeg/jpg/png/webp/gif by sniffed MIME) returns 422 with an `errors.avatar` key + - `RemoveAvatar` with no existing avatar returns 422 `{"error":"Your account has no display picture to remove.","errors":{"avatar":["Your account has no display picture to remove."]}}` + - The `postcard` memory driver records a `mail.activate` send with a non-empty `link`/`code` var when register's user-mode branch runs + - `user:require-password-change alice@example.com` sets `must_change_password=true` for that row and is listed by `Commands()` + + Avatar upload/remove round-trip through Phase 5's attach primitives with the exact D-04 payload shape; all three mail templates render and send through the memory driver in tests; the console command sets must_change_password and is discoverable via Commands(). + + + + + +## 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 | + + + + +`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. + + + +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. + + + +Create `.planning/phases/07-user-plugin-and-authentication/07-03-SUMMARY.md` when done + diff --git a/.planning/phases/07-user-plugin-and-authentication/07-04-PLAN.md b/.planning/phases/07-user-plugin-and-authentication/07-04-PLAN.md new file mode 100644 index 0000000..8efffe7 --- /dev/null +++ b/.planning/phases/07-user-plugin-and-authentication/07-04-PLAN.md @@ -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" +--- + + +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. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.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 + + + +```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. + + + + + + + Task 1: ApiTokenManager — mint and revoke + + ../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/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) + + + - 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`. + + + 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())`. + + + go vet ./... && go test ./plugins/golem15/fonoteka/classes/auth/... -run TestMintPersonalToken -short + + + - `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 + + MintPersonalToken's hash format is byte-identical to what TokenGuard verifies (proven by a round-trip test); MINTABLE_SCOPES/DefaultScopes match PHP's constants. + + + + Task 2: Token CRUD controller, me/locale controller, route groups, locale wiring + + ../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/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) + + + - 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":[],"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":[],"connected_apps_count":}`, 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": ""}` 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. + + + 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(...)`. + + + go vet ./... && go test ./plugins/golem15/fonoteka/... -run 'TestTokenApi|TestMeLocale' -short + + + - `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` + + 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. + + + + Task 3: Route-table 423-exempt assertion and manifest flip + ../fonoteka.go/plugins/golem15/fonoteka/routes_isolation_test.go, ../fonoteka.go/parity/manifest.yaml + + ../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) + + + 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). + + + go vet ./... && go test ./plugins/golem15/fonoteka/... -run TestRequirePasswordChangeExemptSet && go test ./parity/... -run TestParityCorpus -short + + + - `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 + + 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. + + + + + +## 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 | + + + + +`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. + + + +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. + + + +Create `.planning/phases/07-user-plugin-and-authentication/07-04-SUMMARY.md` when done + diff --git a/.planning/phases/07-user-plugin-and-authentication/07-05-PLAN.md b/.planning/phases/07-user-plugin-and-authentication/07-05-PLAN.md new file mode 100644 index 0000000..0539615 --- /dev/null +++ b/.planning/phases/07-user-plugin-and-authentication/07-05-PLAN.md @@ -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" +--- + + +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`. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.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 + + + +```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 ` runs any artisan command (e.g. `tinker`) against the SAME isolated DB — useful for a one-off read without a Go SQLite driver. + + + + + + + Task 1: Start the isolated PHP instance + ../fonoteka.go/parity/php_parity.sh + ../fonoteka.go/parity/php_parity.sh (reset/serve/artisan subcommands and the refuse_db guard, already read this session) + + 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. + + + curl -sf http://127.0.0.1:8423/_user/api/v1/oauth-providers + + + Confirm the isolated PHP instance is running against the parity-only SQLite database, not the developer's own database, before any recording begins. + + Type "ready" once the isolated PHP instance is reachable at 127.0.0.1:8423. + + - `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 + + The isolated PHP instance is reachable at 127.0.0.1:8423 and backed by the parity-only SQLite database. + + + + Task 2: Record the 15 /_user/api/v1 routes and the nuxt-auth flow + + ../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 + + + ../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) + + + 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','')->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= --target=http://127.0.0.1:8423 --rules=capture-rules.yaml --vars= --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. + + + go test ./parity/... -run 'TestParityCorpus|TestDBCapture' -short + + + - `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` + + 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. + + + + Task 3: Verify no secrets leaked and the corpus replays green + ../fonoteka.go/parity/fixtures/routes, ../fonoteka.go/parity/fixtures/nuxt/nuxt-auth.yaml, ../fonoteka.go/parity/manifest.yaml + the fixture files Task 2 recorded, ../fonoteka.go/parity/manifest.yaml + + 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. + + + ! 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 + + + 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. + + 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. + + - 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 + + 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. + + + + + +## 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 | + + + + +`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). + + + +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. + + + +Create `.planning/phases/07-user-plugin-and-authentication/07-05-SUMMARY.md` when done + diff --git a/.planning/phases/07-user-plugin-and-authentication/07-06-PLAN.md b/.planning/phases/07-user-plugin-and-authentication/07-06-PLAN.md new file mode 100644 index 0000000..74ece3c --- /dev/null +++ b/.planning/phases/07-user-plugin-and-authentication/07-06-PLAN.md @@ -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" +--- + + +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`. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.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 + + + + + + Task 1: Complete summercms.go coverage — bouncer, surf, lagoon + 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 + + 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`) + + + - Any behavior from 07-01's Task 2/3 `` 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`. + + + 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). + + + go vet ./... && go test ./bouncer/... ./surf/... ./lagoon/... -race -cover + + + - `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` + + go vet and go test -race are green across bouncer/surf/lagoon; every exported Phase 7 symbol in these packages has a direct test. + + + + Task 2: Complete fonoteka.go coverage — user plugin, fonoteka plugin, mail + + ../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 + + + ../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) + + + - 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. + + + 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. + + + go vet ./... && go test ./... -race -cover + + + - `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 + + 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. + + + + Task 3: Finalize 07-VALIDATION.md and run the full phase gate + .planning/phases/07-user-plugin-and-authentication/07-VALIDATION.md + + .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) + + + 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. + + + go vet ./... && go test ./... -race + + + - `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 + + 07-VALIDATION.md has zero TBD/pending rows, nyquist_compliant: true; the full phase gate (both repos + parity replay) is green. + + + + + +## 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. + + + + +`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. + + + +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. + + + +Create `.planning/phases/07-user-plugin-and-authentication/07-06-SUMMARY.md` when done +