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

29 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, user_setup, must_haves
phase plan type wave depends_on files_modified autonomous requirements user_setup must_haves
07-user-plugin-and-authentication 01 execute 1
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
false
AUTH-01
I18N-02
truths artifacts key_links
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)
bouncer.HashPassword defaults to bcrypt cost 10 (config-driven), a real PHP $2y$ hash verifies unchanged via CheckPassword, and NeedsRehash flags a hash whose cost is lower than configured for silent rehash on login, per D-19
path provides
bouncer/mint.go Mint(secret, sub, issuerURL string, ttl time.Duration) (token, jti string, err error) with the hardcoded prv constant
path provides
bouncer/refresh.go Refresh(secret, tokenString string, refreshTTL time.Duration, bl BlacklistStore, grace time.Duration, issuerURL string) (string, error) using jwt.WithoutClaimsValidation
path provides
bouncer/blacklist.go BlacklistStore interface, MemoryBlacklist, PostgresBlacklist(db *sql.DB, table string) with Add/IsBlacklisted/Sweep
path provides
bouncer/password.go HashPassword/CheckPassword/NeedsRehash over golang.org/x/crypto/bcrypt
path provides
surf/locale_from_principal.go LocaleFromPrincipal middleware registered globally as locale.from-principal in BuildRouter
from to via pattern
bouncer/jwt.go jwtGuard.Authenticate bouncer/blacklist.go BlacklistStore.IsBlacklisted optional bl argument on NewJWTGuard, checked when non-nil IsBlacklisted
from to via pattern
bouncer/jwt.go jwtGuard.Authenticate bouncer/context.go Principal.TokensValidAfter iat comparison after FindByID, generic D-20 cutoff 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.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>

@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/07-user-plugin-and-authentication/07-CONTEXT.md @.planning/phases/07-user-plugin-and-authentication/07-RESEARCH.md @.planning/phases/07-user-plugin-and-authentication/07-PATTERNS.md

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):

type Principal struct {
	ID                 uint
	MustChangePassword bool
}

From bouncer/guard.go / registry.go (current, unchanged this plan):

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:

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):

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):

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:

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 <table> WHERE jti = $1`; returns `true` only when a row exists AND `now >= valid_until` (Pitfall 2 — row existence alone is not enough during the grace window). `Sweep` runs `DELETE FROM <table> WHERE expires_at < $1`. Table/column identifiers come only from the constructor argument (validate with the same `identName` regex idiom `lagoon.Validate`'s `uniqueOK` uses, to avoid building a SQL string from unchecked input) — table name is a compile-time constant from the calling plugin, not user input, but validate it defensively anyway.

Extend `bouncer/jwt.go`: change `NewJWTGuard`'s signature to `func NewJWTGuard(secret string, users UserProvider, bl BlacklistStore, cookieNames ...string) Guard` (`bl` may be `nil` to skip the blacklist check; `cookieNames` empty means Bearer-only). Add an unexported `extractToken(r *http.Request, cookieNames []string) (string, error)`: try `bearerToken(r)` first; on failure, if `cookieNames` is non-empty, try each cookie name in order via `r.Cookie(name)`, first non-empty `Value` wins; if nothing found, return the existing `msgTokenNotProvided` error. Add an EXPORTED `func VerifyClaims(tokenString, secret string) (sub string, iat, exp time.Time, jti string, err error)` — same parser as `Verify` (HS256, `WithExpirationRequired`) but also extracting `iat`/`exp`/`jti` from the already-validated `jwt.MapClaims`, reusing `subject()`; this is exported deliberately (not kept package-private) because 07-02's `logout`/`refresh` handlers and 07-03's `change-password` handler all need the presenting token's `iat`/`jti`/`exp` outside the `bouncer` package — one parse implementation, no duplicated JWT-claims code in the user plugin. In `jwtGuard.Authenticate`: use `extractToken` instead of the bare `bearerToken` call, `VerifyClaims` instead of `Verify`, then after `FindByID` succeeds: if `bl != nil`, check `bl.IsBlacklisted(r.Context(), jti)` and fail with the existing generic path (reuse whatever `write401`/error text the guard already uses for "token not usable" — do not invent new wire text); then if `!principal.TokensValidAfter.IsZero() && iat.Before(principal.TokensValidAfter)`, fail with `errors.New(msgUserNotFound)` (D-20's "the normal 401"). Update every existing in-package call site of `NewJWTGuard` (tests) to pass `nil` for `bl` and no cookie names unless the test specifically exercises cookies/blacklist. Leave the top-level `Middleware` function and `Verify` untouched (C-01 — still Bearer-only, still `exp`-required, still the exact existing 401 bodies) since nothing in this phase repoints its callers.

Extend `bouncer/context.go`: add `PreferredLocale string` and `TokensValidAfter time.Time` to `Principal` (zero value = no override / no cutoff, both purely additive per RESEARCH.md's Pattern 3 and D-20).
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.

<threat_model>

Trust Boundaries

Boundary Description
Client → bouncer.Verify/jwtGuard Untrusted bearer token / cookie value crosses into JWT parsing and claim trust decisions
golang.org/x/crypto supply chain A new direct dependency crosses into the password-hashing trust boundary

STRIDE Threat Register

Threat ID Category Component Disposition Mitigation Plan
T-07-01 Spoofing / Elevation of Privilege bouncer.jwtGuard.Authenticate mitigate BlacklistStore.IsBlacklisted checked on every authenticated request when a store is wired (Task 2); a forever-blacklisted (logged-out) jti is rejected with the existing generic 401 shape
T-07-02 Denial of Service (self-inflicted) bouncer.Refresh + BlacklistStore mitigate Grace-windowed blacklist (valid_until, not mere row existence) so a just-rotated token stays usable for blacklist_grace seconds — Pitfall 2
T-07-07 Tampering bouncer.Verify / NewJWTGuard accept (already mitigated, re-asserted) Empty JWT secret already fails boot since Phase 3 (C-01); this plan does not touch secret loading, only extraction and the blacklist/cutoff checks layered on top
T-07-11 Tampering / Race bouncer.PostgresBlacklist mitigate Add is an upsert (ON CONFLICT (jti) DO UPDATE), IsBlacklisted/Sweep are single indexed statements — no read-then-write race window inside the store itself
T-07-SC Tampering (supply chain) golang.org/x/crypto (new direct dependency) mitigate Blocking checkpoint:human-verify (Task 1) before promotion, citing the Package Legitimacy Audit override rationale (official Go team module, already transitively present, slopcheck [SUS] verdict is a documented false positive)

</threat_model>

`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`).

<success_criteria> Every later Phase 7 plan can import bouncer.Mint, bouncer.Refresh, bouncer.BlacklistStore/NewPostgresBlacklist, bouncer.HashPassword/CheckPassword/NeedsRehash, bouncer.Principal.PreferredLocale/TokensValidAfter, surf.LocaleFromPrincipal (registered as "locale.from-principal"), and the four new lagoon.Validate tokens without any further framework-level design work. </success_criteria>

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