`). Go modules cannot be checked by it, which is the same situation as 19.4 (`.planning/notes/2026-10-19-oidc-on-wristband-golang-jwt.md`: "the package-legitimacy seam cannot check Go modules"). Signals were gathered by hand:
+
+| Package | Registry | Age | Downloads | Source Repo | Verdict | Disposition |
+|---------|----------|-----|-----------|-------------|---------|-------------|
+| github.com/markbates/goth | proxy.golang.org | v1.82.0 tagged 2025-08-18; repo has 825 commits | n/a (Go). 6.6k GitHub stars, 629 forks | github.com/markbates/goth (MIT) | seam N/A; govulncheck clean; [ASSUMED] for legitimacy per the provenance rule | **Planner adds `checkpoint:human-verify` before `go get`** (19.4 precedent) |
+| golang.org/x/oauth2 | proxy.golang.org | Go-team module, already in workspace graph | — | go.googlesource.com/oauth2 | already approved (19.4 note) | Transitive, no action |
+| cloud.google.com/go/compute/metadata | proxy.golang.org | already in workspace graph (v0.9.0 selected in demo) | — | github.com/googleapis/google-cloud-go | already present | Transitive, no action |
+
+**Packages removed due to [SLOP] verdict:** none
+**Packages flagged as suspicious [SUS]:** none. goth is gated by a human check because the seam cannot evaluate Go and the project shows maintainer-search signals.
+
+## Architecture Patterns
+
+### System Architecture Diagram
+
+```
+ SPA (Horoskopia / bm / demo) sociallogin plugin Provider (Google/FB/GitHub/Entra)
+ ───────────────────────────── ────────────────── ─────────────────────────────────
+ click "Sign in with X"
+ │ top-level GET /oauth/{p}?mode=spa&action=…&frontend_callback=…&return_to=…[&consent…]
+ ▼
+ ┌──────────────────────────────────────────────┐
+ │ throttle:20,1 → registry: name registered & │──unknown name──► 404
+ │ enabled (settings ⊕ config)? │──disabled──────► 302 {callback}?error=provider_not_configured
+ │ normalize callback (D-17) + return_to │
+ │ action=link? RequestUser(bearer|token cookie)│──none──────────► 302 {callback}?error=must_be_logged_in
+ │ state=rand; goth.BeginAuth(state) │
+ │ Set-Cookie: sealed{state,ctx,consent,sess} │
+ └───────────────┬──────────────────────────────┘
+ │ 302 authURL ────────────────────────────────────────────────────────► user authenticates
+ │ │
+ GET /oauth/{p}/callback?code&state ◄────────────────────────────────────────────────────┘
+ ▼
+ ┌──────────────────────────────────────────────┐
+ │ open+clear cookie; constant-time state cmp │──fail──► 302 {callback}?error=invalid_state
+ │ goth Unmarshal → Authorize(code) → FetchUser │──invalid_grant──► ?error=code_expired
+ │ Provider wrapper → Profile{ID,Email,Verified} │
+ └───────────────┬──────────────────────────────┘
+ ▼
+ ┌──────────────────────────────────────────────┐ identity (provider,provider_id) ──► login
+ │ match (D-10/D-11) via user-plugin helpers │ no email ─────────────────────────► pending(fb_no_email)
+ │ DB tx: upsert identity / create user │ email match & !verified ─────────► pending(unverified_match)
+ │ │ email match & verified ──────────► link + login
+ │ │ none & action=login ─────────────► pending()
+ │ │ none & action=register ──────────► create user (consent ≤600s)
+ └───────┬───────────────────────────────┬──────┘
+ │ cooler.Set oauth-complete:{c} │ cooler.Set oauth-pending-registration:{c}
+ ▼ ▼
+ 302 {callback}?code=c 302 {callback}?pending_registration=c[&reason][&provider][&email]
+ │ │
+ SPA: GET /_user/api/v1/oauth-complete SPA consent page: POST /_user/api/v1/oauth-register-complete
+ │ cooler.Pull → 200 {token,user,action,return_to} │ Get → validate consent (422 keeps code)
+ │ (D-09 flag: Set-Cookie token, no token in body) │ → Pull → create/login → 200 / 410 / 422
+ ▼ ▼
+ SPA stores session, navigates to return_to
+
+ user plugin: GET /_user/api/v1/oauth-providers ──app.Lookup[classes.OAuthProviderSource]──► sociallogin (enabled list)
+ host app: DELETE …/oauth-identities/{p} ──► user plugin: tx{ SELECT users FOR UPDATE; guarded DELETE }
+```
+
+### Recommended Project Structure
+```
+sm-sociallogin-plugin/ # git.golem15.com/golem15/sm-sociallogin-plugin, package sociallogin
+├── plugin.go # PluginID "golem15.sociallogin", Requires golem15.user, Register/Boot, interface asserts
+├── routes.go # /oauth group (throttle:20,1) + /_user/api/v1 oauth-complete|oauth-register-complete
+├── config.go # LoadConfig: frontend_url, callback_path, allowed_hosts, token_cookie, beta_key, timeouts
+├── admin_settings.go # pact.HasSettings (pgstripe-style singleton, SecretFallbacks)
+├── classes/
+│ ├── provider.go # exported Provider, Profile, ProviderConfig, Registry, Register func
+│ ├── providers_goth.go # google/facebook/github/microsoft wrappers (only file importing goth)
+│ ├── statecookie.go # seal/open (AES-GCM, HKDF label), size bound
+│ ├── callback.go # normalizeFrontendCallback / normalizeReturnTo
+│ ├── matcher.go # D-10 branches, linking, pending payload
+│ └── codes.go # 64-char codes, cooler Set/Pull wrappers
+├── controllers/ # redirect, callback, complete, register-complete handlers
+├── models/settings.go + models/settings/fields.yaml
+├── updates/ # golem15_sociallogin_settings migration (+ seed row 1)
+├── config/config.yaml lang/{en,pl}/lang.yaml
+├── README.md go.mod go.work
+└── *_test.go # harness_test.go/testmain_test.go copied from oauthserver
+```
+
+### Pattern 1: Provider registry of factories, built per request
+**What:** The registry stores `name → factory`. A factory receives the **current** credentials (settings row ⊕ config), builds a fresh provider (goth `New` only fills an `oauth2.Config`, so this is cheap) and returns the plugin-owned interface. Changed settings take effect on the next request without a restart (D-16), and tests register fakes through the same registry (D-03).
+**When to use:** all four built-ins, plus app-registered providers.
+**Shape** (identifiers are Claude's discretion):
+```go
+// classes/provider.go — no goth import here.
+type Profile struct {
+ ID, Email, Name, AvatarURL string
+ EmailVerified bool // decided by the provider wrapper (D-11)
+ AccessToken, RefreshToken string
+ ExpiresAt time.Time // zero = none
+ Raw map[string]any
+}
+type ProviderConfig struct{ ClientID, ClientSecret, RedirectURL, Tenant string; HTTPClient *http.Client }
+type Provider interface {
+ Name() string
+ Label() string
+ // Begin returns the authorization URL and an opaque session the plugin seals in its cookie.
+ Begin(state string) (authURL, session string, err error)
+ // Complete exchanges the callback parameters and returns the profile.
+ Complete(ctx context.Context, session string, callback url.Values) (Profile, error)
+}
+type Factory func(ProviderConfig) (Provider, error)
+type Registry struct{ /* mu, order []string, factories map[string]Factory, labels */ }
+func (r *Registry) Register(name, label string, f Factory) error // rejects reserved names (Pitfall 9) and duplicates
+```
+Publish the `*Registry` on the app in `Register` (before dependents boot), so another plugin that `Requires("golem15.sociallogin")` can `app.Lookup[*classes.Registry]()` and add a provider.
+
+### Pattern 2: Sealed state cookie
+Seal and open with AES-256-GCM under `hkdf.Key(sha256.New, appKey, nil, "golem15.sociallogin.oauth-state.v1", 32)`. Use the cookie name as AAD. Add a version byte and an `iat` inside, and reject anything older than the TTL even if the browser still sends it. Bound the plaintext (for example `return_to` ≤ 1024 bytes, total sealed ≤ 3800 bytes). Cookie: `HttpOnly`, `SameSite=Lax`, `Path=/oauth`, `MaxAge=600`, `Secure` = `bouncer.LoadCookieConfig(app.Config).Secure`. Consider one cookie name per provider (`summer_oauth_`) so two tabs on different providers don't clobber each other. PHP shared one session key, so this is discretionary.
+
+### Pattern 3: Provider list through the service registry (D-15)
+sm-user-plugin declares the interface, because sociallogin imports the user plugin and not the reverse:
+```go
+// sm-user-plugin classes/oauth_providers.go (additive)
+type OAuthProvider struct{ Name, Label string }
+type OAuthProviderSource interface {
+ EnabledOAuthProviders(ctx context.Context) []OAuthProvider // enabled only, registry order
+ KnownOAuthProviders() []string // for unlink's allow-list
+}
+```
+sociallogin Boot: `app.Publish[userclasses.OAuthProviderSource](svc)`. The **explicit type argument** is required (Pitfall 7). `OAuthProviders` does the lookup **per request**, because the user plugin boots first.
+
+### Anti-Patterns to Avoid
+- **Importing `gothic` or `goth.UseProviders`.** gothic drags in gorilla/sessions, and `goth.UseProviders` is a process-global map that breaks parallel tests and multi-app processes. Keep providers in the plugin registry.
+- **Echoing the input `frontend_callback`.** Always rebuild `scheme://host[:port]` + the configured path from parsed parts.
+- **Get, then validate, then Forget** for codes. Use `Pull` as the single consume step.
+- **Count-then-delete or one guarded DELETE without a lock** for unlink (Pitfall 1).
+- **Putting the JWT in the redirect URL** (`?token=`). Only the 64-char one-time code goes in URLs.
+- **Logging `Profile.AccessToken`, `RefreshToken`, the client secret or the full `RetrieveError.Body`.**
+
+## Don't Hand-Roll
+
+| Problem | Don't Build | Use Instead | Why |
+|---------|-------------|-------------|-----|
+| OAuth code exchange, token parsing | `net/http` POST + JSON parsing | goth providers on `golang.org/x/oauth2` | Auth-style detection (header vs body), `RetrieveError` parsing, expiry |
+| Provider endpoint URLs and profile mapping | own constants | goth packages (`google.Endpoint`, `azureadv2` tenant templates, Graph `/me`) | Already correct and maintained |
+| Authenticated encryption | XOR/HMAC-only/CBC schemes | stdlib `cipher.NewGCM` + `crypto/hkdf` | AEAD with a fresh nonce is the standard construction |
+| Constant-time compare | `==` on state/beta key | `crypto/subtle.ConstantTimeCompare` (or `hmac.Equal`) | Timing side channel (D-12) |
+| Random codes | `math/rand` | `crypto/rand` (`rand.Read` 48 bytes → `base64.RawURLEncoding` = 64 chars) | Unpredictability |
+| Get-and-delete | Lua script or MULTI | Redis `GETDEL` / otter `Compute` + `InvalidateOp` | Atomic primitives exist |
+| JWT verification for link | parsing the cookie yourself | `userclasses.RequestUser` | Blacklist and `tokens_valid_after` handled |
+| Encrypted secret settings | own encryption of client secrets | `lagoon.Encrypted` columns + `cabana.SecretFallbacks` | Write-only admin UX already exists |
+
+**Key insight:** the protocol code is the risky part, and goth plus x/oauth2 cover it. Everything the plugin must still own (state binding, redirect allow-listing, matching) is policy, and the tests cover it with fake providers.
+
+## Common Pitfalls
+
+### Pitfall 1: PHP's "atomic" guarded DELETE races on Postgres (D-13)
+**What goes wrong:** Two concurrent unlinks (google, facebook) on a password-less two-identity user both delete, leaving zero identities and locking the user out.
+**Why it happens:** Under READ COMMITTED, each statement's `COUNT(*)` subquery sees its own snapshot (count = 2), and the two DELETEs touch different rows, so neither waits for the other. The PHP comment says the opposite. Its test (`test_concurrent_unlink_cannot_strip_last_identity`) runs the two unlinks sequentially.
+**Evidence** [VERIFIED: throwaway `postgres:16` container, this session]: session A `BEGIN; ; pg_sleep(3); COMMIT;` while session B runs `` gave `B: DELETE 1`, `A: DELETE 1`, remaining identities `(0 rows)`. With `SELECT id FROM users WHERE id=1 FOR UPDATE` first in both transactions: `A: DELETE 1`, `B: DELETE 0`, one `facebook` row left.
+**How to avoid:** `OAuthIdentitiesDestroy` runs `db.Transaction(func(tx) { tx.Exec("SELECT id FROM users WHERE id = ? FOR UPDATE", uid); res := tx.Exec(guardedDelete, uid, provider); … })`. `RowsAffected == 0` after confirming the row exists maps to 409 `last_method_blocked`. The linking and creation paths that add identities should take the same lock.
+**Warning signs:** a test that runs the two deletes with goroutines and no lock passes only by chance. Make the test deterministic with two explicit transactions, holding the first open.
+
+### Pitfall 2: Columns D-10 names do not exist in Go
+**What goes wrong:** The plan writes `password_bootstrap_source`, `username`, consent columns, a consent audit or a default group, and none of them exist in the Go schema (Q5), so it fails at migrate or compile time.
+**How to avoid:** Decide Open Question 1 first. `RegisterUser` also needs a way to create with `has_self_set_password=false`: add `RegisterOptions.PasswordBootstrap bool` (or set it in the same transaction after create). Pass a 32-byte random password plus confirmation to satisfy the `password` rule.
+
+### Pitfall 3: GitHub email via goth is not Socialite's
+**What goes wrong:** (a) goth `github.New` defaults to **no scopes**, so it never calls `/user/emails` (github.go:143-153, 217-233). (b) When the public profile email is set, goth returns it **without** checking primary or verified. (c) When no primary+verified email exists, `FetchUser` **returns an error** (`ErrNoVerifiedGitHubPrimaryEmail`, github.go:37, :214) instead of an empty email, so the flow would fail as `provider_error` instead of pending `fb_no_email`.
+**How to avoid:** Request scopes `read:user`, `user:email`. In the wrapper, call `https://api.github.com/user/emails` with the token and pick `primary && verified` (or ignore goth's email). On `ErrNoVerifiedGitHubPrimaryEmail`, return an empty email. Then `EmailVerified=true` holds by construction, which is D-11's PHP rationale (SocialAuth.php:903-911).
+
+### Pitfall 4: Google verified flag and scopes
+**What goes wrong:** goth Google calls the **v2** userinfo endpoint `https://www.googleapis.com/oauth2/v2/userinfo` (google.go:17), whose field is **`verified_email`** (boolean). It does not return `email_verified`. A port that checks only `email_verified` treats every Google user as unverified. goth's default scope is only `"email"` (google.go:150-152), so `name` and `picture` come back empty.
+**How to avoid:** `EmailVerified = raw["verified_email"] == true || raw["email_verified"] == true`, with strict bool type assertions. Scopes `openid`, `email`, `profile`. `SetPrompt("select_account")`. Note that goth sets `AccessTypeOffline` by default (google.go:31-33). [VERIFIED: Google discovery doc oauth2/v2 `Userinfo.verified_email` "Boolean flag which is true if the email address is verified."]
+
+### Pitfall 5: Microsoft provider name, scopes, avatar, email source
+`azureadv2` names itself `"azureadv2"`, omits `offline_access` by default, sets an auth-only Graph photo URL as `AvatarURL`, and takes email from Graph `mail` (often empty for personal accounts). Fix all four in the wrapper (Q2). `EmailVerified` is always false (D-11).
+
+### Pitfall 6: Facebook defaults
+`facebook.New` asks Graph for `email,first_name,last_name,link,about,id,name,picture,location` on unversioned URLs (facebook.go:22-41). `about`/`location`/`link` need extra permissions. Use `SetCustomFields([]string{"id","name","email","first_name","last_name","picture"})`. goth already sends `appsecret_proof` (facebook.go:109-113). It cannot pin a Graph version (the URLs are constants), so the API version follows the Facebook app's dashboard default. **Recommendation for the discretion item: don't pin it in config.**
+
+### Pitfall 7: Service published under the wrong type
+`app.Publish(svc)` keys by the concrete type, so `app.Lookup[classes.OAuthProviderSource]()` misses it and `oauth-providers` silently returns `[]`. Use `app.Publish[userclasses.OAuthProviderSource](svc)` (precedent: user plugin.go:93) and test the lookup through the real boot harness.
+
+### Pitfall 8: Boot order and no DB at Boot
+sociallogin boots after the user plugin, and sm-demo-app's `TestBootAllPlugins` boots with no DB. Resolve DB, cache and settings at request time (or in `lagoon.OnDatabase`), never in `Boot`.
+
+### Pitfall 9: `/oauth/{provider}` shadows other `/oauth/*` GETs, and provider names must avoid them
+[VERIFIED: ServeMux probe with oauthserver's patterns + sociallogin's.] There is no registration panic. Literal routes (`/oauth/jwks`, `/oauth/authorize`, `/oauth/mcp/authorize`) win. But `GET /oauth/token` (POST-only upstream) and `GET /oauth/mcp/callback` fall into the sociallogin handlers. **Unknown names must 404**, and `Registry.Register` should reject `authorize, authorizations, jwks, token, userinfo, mcp` and `.well-known`-like names.
+
+### Pitfall 10: Email matching is case-sensitive on Postgres
+`LookupByEmail` uses `email = ?` and the column is `TEXT … UNIQUE` (case-sensitive). The PHP apps used MySQL with a case-insensitive collation [ASSUMED]. A Google `John@Gmail.com` would miss `john@gmail.com`, go pending, and then fail the users-email uniqueness check, or create a near-duplicate account. **Match with `lower(email) = lower(?)`** for OAuth matching.
+
+### Pitfall 11: Soft-deleted users
+The identity lookup through the default GORM scope hides a deactivated user, while `LookupByEmail` is `Unscoped`. An OAuth login for a deactivated account can fall through to "create user" and then hit the email UNIQUE constraint. Decide the behaviour explicitly (Open Question 3).
+
+### Pitfall 12: Memory cache on more than one instance
+D-07 makes the state cookie instance-independent, but the two codes live in `cooler`. With `cache.driver: memory` behind a load balancer, the SPA's `oauth-complete` can land on another instance and get 410. Document that multi-instance apps need `cache.driver: redis` (Redis ≥ 6.2 for `GETDEL`).
+
+### Pitfall 13: Diverged sm-user-plugin history and concurrent sessions
+The 19.4 commits (`RequestUser`, …) are unpushed in fonoteka's checkout. 19.4 is still "verifying" (STATE.md), and 19.6 (2FA) will also modify sm-user-plugin. Sequence the work: merge `24b16b1` into the fonoteka line (one conflict hunk, Q5), push with the user's go-ahead, then build sociallogin against the pushed line. Leave other sessions' uncommitted edits alone.
+
+### Pitfall 14: The beta gate is bypassable through the pending path (PHP bug)
+PHP validates `beta_key` only in `handleOAuthRegistration`, the `action=register` path (SocialAuth.php:495-505). `completePendingRegistration` never checks it, so `action=login` → pending → `oauth-register-complete` creates accounts with the gate on. **Carry `beta_key` from the start request through the cookie into the pending payload, and check it in constant time at completion too.**
+
+### Pitfall 15: Consent `600 s` vs cookie lifetime
+The consent timestamp check (≤600 s) and a 10-minute cookie are the same window, and a slow provider login can just expire. Keep the cookie Max-Age at 600 s and check consent age at the callback, as PHP does. The error is `consent_expired`.
+
+## Code Examples
+
+### 1. goth without gothic inside a wrapper (Google)
+```go
+// Source: goth@v1.82.0 providers/google/{google.go,session.go}; gothic/gothic.go:184-237 (flow reimplemented)
+func newGoogle(cfg classes.ProviderConfig) (classes.Provider, error) {
+ gp := google.New(cfg.ClientID, cfg.ClientSecret, cfg.RedirectURL, "openid", "email", "profile")
+ gp.SetPrompt("select_account")
+ gp.HTTPClient = cfg.HTTPClient // has a Timeout; goth falls back to http.DefaultClient otherwise
+ return &gothProvider{name: "google", label: "Google", gp: gp, verified: func(u goth.User) bool {
+ v1, _ := u.RawData["verified_email"].(bool)
+ v2, _ := u.RawData["email_verified"].(bool)
+ return v1 || v2
+ }}, nil
+}
+
+func (p *gothProvider) Begin(state string) (string, string, error) {
+ sess, err := p.gp.BeginAuth(state)
+ if err != nil { return "", "", err }
+ u, err := sess.GetAuthURL()
+ if err != nil { return "", "", err }
+ return u, sess.Marshal(), nil
+}
+
+func (p *gothProvider) Complete(ctx context.Context, blob string, cb url.Values) (classes.Profile, error) {
+ sess, err := p.gp.UnmarshalSession(blob)
+ if err != nil { return classes.Profile{}, err }
+ if _, err := sess.Authorize(p.gp, cb); err != nil { return classes.Profile{}, err } // *oauth2.RetrieveError on invalid_grant
+ u, err := p.gp.FetchUser(sess)
+ if err != nil { return classes.Profile{}, err }
+ return classes.Profile{ID: u.UserID, Email: u.Email, Name: u.Name, AvatarURL: u.AvatarURL,
+ EmailVerified: p.verified(u), AccessToken: u.AccessToken, RefreshToken: u.RefreshToken,
+ ExpiresAt: u.ExpiresAt, Raw: u.RawData}, nil
+}
+// Microsoft: azureadv2.New(id, secret, cb, azureadv2.ProviderOptions{Tenant: azureadv2.TenantType(tenant),
+// Scopes: []azureadv2.ScopeType{azureadv2.OpenIDScope, azureadv2.ProfileScope, azureadv2.EmailScope,
+// azureadv2.OfflineAccessScope, azureadv2.UserReadScope}}); verified always false.
+```
+(`OpenIDScope ScopeType = "openid"` :30, `EmailScope ScopeType = "email"` :36, `ProfileScope ScopeType = "profile"` :43, `OfflineAccessScope ScopeType = "offline_access"` :57, `UserReadScope ScopeType = "User.Read"` :670, all in `providers/azureadv2/scopes.go`.)
+
+### 2. Seal and open the state cookie (stdlib)
+```go
+// Source: stdlib crypto/hkdf (as lagoon/encrypted.go:45-49 uses it), crypto/cipher GCM
+const stateKeyInfo = "golem15.sociallogin.oauth-state.v1"
+
+func stateAEAD(app *backpack.App) (cipher.AEAD, error) {
+ primary, _, err := lagoon.LoadAppKey(app.Config) // 32 bytes or error naming app.key
+ if err != nil { return nil, err }
+ key, err := hkdf.Key(sha256.New, primary, nil, stateKeyInfo, 32)
+ if err != nil { return nil, err }
+ block, err := aes.NewCipher(key)
+ if err != nil { return nil, err }
+ return cipher.NewGCM(block)
+}
+
+func seal(aead cipher.AEAD, cookieName string, plain []byte) (string, error) {
+ nonce := make([]byte, aead.NonceSize())
+ if _, err := rand.Read(nonce); err != nil { return "", err }
+ return base64.RawURLEncoding.EncodeToString(aead.Seal(nonce, nonce, plain, []byte(cookieName))), nil
+}
+
+func open(aead cipher.AEAD, cookieName, value string) ([]byte, error) {
+ raw, err := base64.RawURLEncoding.DecodeString(value)
+ if err != nil || len(raw) < aead.NonceSize() { return nil, errInvalidState }
+ n := aead.NonceSize()
+ return aead.Open(nil, raw[:n], raw[n:], []byte(cookieName)) // then JSON-decode, check v, iat ≤ 600 s
+}
+```
+
+### 3. `cooler.Cache.Pull`
+```go
+// Interface addition (modules/cooler/cooler.go):
+// // Pull returns the value at key and removes it in one atomic step;
+// // found is false when the key is missing or expired.
+// Pull(ctx context.Context, key string) ([]byte, bool, error)
+
+// Redis (rediscache.go) — go-redis v9 GetDel, Redis >= 6.2:
+func (r *redisCache) Pull(ctx context.Context, key string) ([]byte, bool, error) {
+ if err := r.ready(); err != nil { return nil, false, err }
+ b, err := r.client.GetDel(ctx, r.key(key)).Bytes()
+ if errors.Is(err, redis.Nil) { return nil, false, nil }
+ if err != nil { return nil, false, err }
+ return b, true, nil
+}
+
+// Memory (memory.go) — otter Compute runs under the per-key lock (like Increment):
+func (m *memoryCache) Pull(_ context.Context, key string) ([]byte, bool, error) {
+ var out []byte
+ found := false
+ now := time.Now()
+ m.c.Compute(m.key(key), func(old memEntry, ok bool) (memEntry, otter.ComputeOp) {
+ if !ok { return old, otter.CancelOp }
+ if !old.expired(now) { out, found = clone(old.val), true }
+ return memEntry{}, otter.InvalidateOp
+ })
+ return out, found, nil
+}
+```
+Test it with N goroutines pulling one key: exactly one gets `found=true` (both drivers, miniredis + `redis:7-alpine`).
+
+### 4. Callback normalisation (PHP :1185-1251 plus D-17)
+```go
+func normalizeFrontendCallback(raw string, allowed hostSet, path string) (string, bool) {
+ if raw == "" { return "", false }
+ u, err := url.Parse(raw)
+ if err != nil || u.Path != path { return "", false }
+ if u.Scheme == "" && u.Host == "" { return path, true } // relative, as PHP returns the constant path
+ if u.Scheme != "http" && u.Scheme != "https" || u.Host == "" { return "", false }
+ host := strings.ToLower(u.Hostname())
+ if !allowed.has(host) { return "", false } // localhost, 127.0.0.1, request host, app.url host, frontend_url host, allowed_hosts
+ return u.Scheme + "://" + u.Host + path, true // rebuilt: no userinfo, query, fragment
+}
+
+func normalizeReturnTo(s string) string {
+ if s == "" || !strings.HasPrefix(s, "/") || strings.HasPrefix(s, "//") ||
+ strings.HasPrefix(s, "/\\") || strings.ContainsAny(s, "\\\r\n\t") || len(s) > 1024 {
+ return "/"
+ }
+ return s
+}
+```
+The backslash, control-character and length checks are stricter than PHP (A5).
+
+### 5. Race-safe unlink (D-13)
+```go
+// Source: measured behaviour (Pitfall 1); WHERE clause ported from PHP User.php:913-932
+err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
+ if err := tx.Exec(`SELECT id FROM users WHERE id = ? FOR UPDATE`, uid).Error; err != nil { return err }
+ res := tx.Exec(`DELETE FROM golem15_user_oauth_identities
+ WHERE user_id = ? AND provider = ?
+ AND (EXISTS (SELECT 1 FROM users u WHERE u.id = golem15_user_oauth_identities.user_id AND u.has_self_set_password)
+ OR (SELECT COUNT(*) FROM golem15_user_oauth_identities oi WHERE oi.user_id = golem15_user_oauth_identities.user_id) > 1)`,
+ uid, provider)
+ if res.Error != nil { return res.Error }
+ deleted = res.RowsAffected
+ return nil
+})
+// existing row + deleted == 0 → 409 last_method_blocked (unchanged body); deleted == 1 → 204
+```
+
+## State of the Art
+
+| Old Approach | Current Approach | When Changed | Impact |
+|--------------|------------------|--------------|--------|
+| `state` only for CSRF | RFC 9700 (OAuth 2.0 Security BCP, Jan 2025): "For confidential clients, the use of PKCE is RECOMMENDED"; without PKCE, "one-time use CSRF tokens carried in the `state` parameter" must be used | 2025 | goth's Google/FB/GitHub/azureadv2 providers expose no PKCE hook (the `oauth2.Config` is unexported). The plugin relies on a browser-bound one-time `state` (D-07), which meets the BCP's requirement without PKCE. PKCE goes on the deferred list [CITED: rfc-editor.org/rfc/rfc9700 §2.1, §2.1.1] |
+| Email claim as identity | `oid`/`tid`/`sub` as identity, email treated as unverified for Entra | nOAuth disclosures (2023+) | D-11 Microsoft is always unverified. The identity key is Graph `/me` `id` [CITED: third-party write-ups summarised in search; Microsoft Q&A quote] |
+| Socialite (PHP) GitHub email = primary+verified | goth GitHub = public email first | — | Pitfall 3 |
+| Google OIDC userinfo v3 `email_verified` | goth uses v2 `verified_email` | — | Pitfall 4 |
+
+**Deprecated/outdated:**
+- `gothic` with the gorilla/sessions store, which D-02 excludes.
+- goth `providers/azuread` (v1 endpoint, `graph.windows.net`) and `microsoftonline` (no tenant). Use `azureadv2`.
+
+## Assumptions Log
+
+| # | Claim | Section | Risk if Wrong |
+|---|-------|---------|---------------|
+| A1 | goth's legitimacy (the seam can't check Go). Treated as needing a human check | Package Legitimacy Audit | Low: well-known library, but the gate stays |
+| A2 | The PHP apps (wavepath/Horoskopia) ran MySQL with case-insensitive email matching | Pitfall 10 | If they were case-sensitive, lower() matching is still safer and only merges verified emails |
+| A3 | Graph `/me` `id` is stable and globally unique enough as `provider_id` for `common` tenants (GUID object ids; 16-hex ids for personal accounts) | Q2 | A cross-tenant collision would need identical GUIDs: negligible. Using `tid:oid` would need ID-token parsing goth doesn't keep |
+| A4 | GitHub only lets users publish a verified address as the public email | Pitfall 3 | Irrelevant if the wrapper always re-reads `/user/emails` as recommended |
+| A5 | Rejecting `/\…`, control characters and >1024-byte `return_to` breaks no client | Code Example 4 | A client passing such paths would land on `/` |
+| A6 | The PHP pending 422 text stays hard-coded Polish, or becomes a pl lang key with a new en text | Q4 | Horoskopia shows `error`. An en text is a new string, not parity |
+| A7 | Giving inactive/banned users real messages instead of PHP's raw missing keys is acceptable | Q4 table | The SPA shows the text only |
+| A8 | Microsoft personal accounts often have an empty Graph `mail` | Q2, Open Question 5 | More `fb_no_email` pending outcomes for Microsoft |
+
+## Open Questions
+
+1. **D-10 fields that the Go schema lacks.** What we know: no `username`, `password_bootstrap_source`, consent columns, consent audit or `default_group` setting exists, and Go `/register` also ignores `terms_accepted`. What's unclear: whether 19.5 adds migrations to sm-user-plugin (an additive, core-plugin contract change) or maps onto what exists.
+ **Recommendation:** map `username` to the existing `handle` (generated by `AssignHandle` inside `RegisterUser`, and from the email local part when the provider gives no name). Set `marketing_consent=true` when opted in. Pass consent (terms/privacy/marketing/versions/IP) in the `RegisterEvent.Payload` so apps can record it. Skip `default_group` (Go `/register` doesn't assign one either). Add only `password_bootstrap_source TEXT NULL` if the user wants the audit marker. **Ask the user.**
+2. **Login event.** Go has no `user.login` event. **Recommendation:** run `TouchLastSeen` and update `last_ip_address` (PHP `touchIpAddress`). Add no new event unless asked.
+3. **Soft-deleted accounts.** An OAuth login for a deactivated user (identity or email). **Recommendation:** mirror password `Login`, which restores (`deleted_at = nil`) and sends the reactivate mail, only for an identity match or a verified email match. Treat an unverified match as `unverified_match` as usual. Never create a second row for an existing (even trashed) email.
+4. **`action=link` for bearer-only SPAs (bm) and D-11 for bm.** What we know: a top-level navigation cannot send `Authorization`, so `RequestUser` sees nobody unless the app uses the token cookie. bm uses localStorage. Microsoft never auto-links, so every existing bm account with a matching email hits `unverified_match` and is told to "sign in and link", which bm cannot do. Options:
+ - (a) a one-time **link ticket**: authenticated `POST /_user/api/v1/oauth-link-ticket` → 60 s code in `cooler`; the SPA navigates `/oauth/microsoft?action=link&link_ticket=…`; the plugin `Pull`s it and stores the uid in the state cookie. This is additive and keeps JWTs out of URLs.
+ - (b) accept `?token=` as tymon/jwt-auth's QueryString parser did in PHP (it leaks the JWT into URLs and proxy logs).
+ - (c) bm switches to cookie transport.
+ - (d) for bm only, treat Microsoft as verified when `tenant` is a specific tenant GUID (the school's own directory). This changes locked D-11 and needs the user.
+
+ **Recommendation:** (a), and raise (d) with the user as a bm-specific question.
+5. **Microsoft email source.** Fall back to `userPrincipalName` when Graph `mail` is empty and the UPN contains `@`? It is unverified either way. **Recommendation:** yes. It only pre-fills the pending screen and the new-user email, and any match is diverted.
+6. **Unlink relaxation in fonoteka.** fonoteka's own PHP (`OAuthIdentityApiController::destroy`) blocks the last identity *even with a password* (409). D-13 relaxes that for fonoteka too. **Recommendation:** accept it (CONTEXT calls it intended), or add `OAuthIdentitiesOptions.BlockLastIdentity bool` so fonoteka keeps byte parity. Confirm with the user.
+7. **Unknown or disabled provider at `/oauth/{p}`.** PHP: route regex → 404; not configured → ApplicationException error page. **Recommendation:** unknown → 404. Disabled, invalid action or link without a user → 302 to the resolved callback with `?error=` (more useful for SPAs; desktop shells only use enabled providers).
+8. **Framework version to pin.** `summercms.go` is 34 commits past `v0.2.4`. The `Pull` release tag (v0.2.5 or v0.3.0) needs the user's go-ahead, plus a changelog post the same day (CLAUDE.md).
+
+## Environment Availability
+
+| Dependency | Required By | Available | Version | Fallback |
+|------------|------------|-----------|---------|----------|
+| Go toolchain | all | ✓ | go1.27.2 | — |
+| Docker (testcontainers Postgres/Redis) | full `go test ./...` | ✓ (socket usable; `postgres:16` and `redis:7-alpine` usable) | 29.9.1 | `-short` skips DB tests |
+| Local Redis | manual `GETDEL` check | ✓ | 7.2.5 | miniredis in tests |
+| Node | bm frontend build/test | ✓ | v22.23.3 | — |
+| proxy.golang.org | `go get goth` | ✓ (downloads worked) | — | — |
+| Google/Facebook/GitHub/Microsoft app registrations + secrets | live UAT | ✗ (not in this environment) | — | Fake providers in tests; human UAT with real credentials; bm needs an Entra app registration with redirect `https:///oauth/microsoft/callback` |
+| git.golem15.com push + repo creation (`sm-sociallogin-plugin`) | delivery | needs the user's go-ahead | — | Work locally, push later |
+
+**Missing dependencies with no fallback:** live provider credentials, for the human verification items only.
+**Missing dependencies with fallback:** none.
+
+## Validation Architecture
+
+### Test Framework
+| Property | Value |
+|----------|-------|
+| Framework | Go `testing` (go1.27.2) + `httptest`; testcontainers-go v0.44.0 Postgres (DB tests skip under `-short`); miniredis v2.40.0 and `redis:7-alpine` for cooler |
+| Config file | none (Go). New `sm-sociallogin-plugin/testmain_test.go` + `harness_test.go` copied from `sm-oauthserver-plugin` |
+| Quick run command | `cd sm-sociallogin-plugin && go test -short ./...`; `cd summercms.go && go test -short ./modules/cooler` |
+| Full suite command | `cd summercms.go && go vet ./... && go test ./modules/cooler ./...`; `cd && go test ./...`; `cd sm-sociallogin-plugin && go vet ./... && go test ./...`; app gates: `cd sm-demo-app && go test ./...`, `cd sm-bm-app && go test ./... && npm --prefix frontend run test && npm --prefix frontend run typecheck`, `cd fonoteka.go && go test ./...` |
+
+### Phase Decisions → Test Map
+| Item | Behavior | Test Type | Automated Command | File Exists? |
+|------|----------|-----------|-------------------|-------------|
+| D-08 | `Pull` returns+removes once; expired → not found; N concurrent pullers → exactly 1 winner (memory, miniredis, real Redis) | unit + docker | `go test ./modules/cooler -run 'TestPull'` | ❌ Wave 0 |
+| D-13 | password user unlinks last identity → 204; password-less last → 409 same body; two concurrent tx unlinks → 1 survives | integration (Postgres) | `go test ./... -run 'TestOAuthIdentitiesDestroy'` (user plugin) | ⚠ update existing `TestOAuthIdentitiesDestroyLastMethodBlockedWithPassword`; new race test ❌ |
+| D-15 | no sociallogin → `{"success":true,"providers":[]}` unchanged; with fake enabled providers → `[{name,label}]` in order; disabled excluded | unit | user plugin `-run TestOAuthProviders`; sociallogin `-run TestProvidersList` | ❌ |
+| D-03 | fake provider registered through registry drives redirect → callback → code → complete end to end | integration | `go test ./... -run TestFlow` (sociallogin) | ❌ |
+| D-04/Pitfall 9 | unknown/reserved name → 404; disabled → `?error=`; registry rejects reserved names | unit | `-run 'TestRoutes|TestRegistry'` | ❌ |
+| D-07 | missing/tampered/expired/mismatched-state cookie → `?error=invalid_state`; cookie cleared on every outcome; attrs HttpOnly, Lax, Path=/oauth, Max-Age 600, Secure from app.url | unit | `-run TestStateCookie` | ❌ |
+| D-06/D-17 | callback normalisation table (relative, http/https, exact hosts incl. allowed_hosts, userinfo/query stripped, bad scheme, wrong path, `//host`) and return_to table | unit (table) | `-run 'TestNormalize'` | ❌ |
+| D-09 | complete: 422 empty, 410 unknown, 200 shape, second use 410; cookie flag → Set-Cookie token + no `token` key | integration | `-run TestOAuthComplete` | ❌ |
+| D-08/D-09 | register-complete: 422 consent keeps code (retry succeeds); 410 bad code; 422 `email_exists_link_account` on unverified match; concurrent double submit → one 200 | integration | `-run TestRegisterComplete` | ❌ |
+| D-10 | branch table: identity login; no email → `fb_no_email`; verified match → link; unverified → `unverified_match`; none+login → pending; none+register → created (consent ≤600 s, allow, throttle); inactive/banned refused | integration | `-run TestMatching` | ❌ |
+| D-11 | Google `verified_email` true/false/missing; GitHub true; Facebook false; Microsoft false even with `mail` = existing email (nOAuth case) | unit (wrapper with RoundTripper fakes) + integration | `-run 'TestEmailVerified|TestNOAuth'` | ❌ |
+| D-12 | beta gate off when empty; wrong key refused; checked on the pending path too | integration | `-run TestBetaGate` | ❌ |
+| D-14 | link resolves via bearer / `token` / `auth_token`; no JWT → `must_be_logged_in`; identity owned by another user → `account_already_linked` | integration | `-run TestLink` | ❌ |
+| D-16 | settings row overrides config; secret never in JSON/logs; enabled iff id+secret; change visible on next request | integration | `-run TestSettings` | ❌ |
+| D-19 | tokens stored encrypted with `token_expires_at`; avatar only in `profile_data.avatar` | integration | `-run TestIdentityTokens` | ❌ |
+| D-20 | route middleware lists `throttle:20,1` / `throttle:user-api` (route-info assertion like fonoteka `phase08_coverage_test.go`) | unit | `-run TestRouteMiddleware` | ❌ |
+| PHP map | `SocialAuthGateTest` (11), `OAuthPendingRegistrationTakeoverTest` (5), `OAuthPasswordBootstrapTest` unlink/bootstrap cases, `OAuthLinkCookieTest` (4), `ProfileOAuthLinkTest` (7): one Go test per PHP case, web-mode recursion cases mapped to "pending always has a callback" | integration | `-run TestPHP` | ❌ |
+| D-21 | demo boots with sociallogin (`TestBootAllPlugins` + `/oauth` routed); bm boots, `/oauth` reserved from SPA fallback, frontend callback view tests; fonoteka suite green at new pin | app tests | per-app commands above | ⚠ update existing |
+| Todo | `user:token:*` and `user:require-password-change` (+ `user:backfill-handles`) open the DB when none is published | unit | user plugin `-run TestConsole` (24b16b1 `console_test.go`) | ✅ after merge |
+
+### Sampling Rate
+- **Per task commit:** the quick command of the touched repo (`go test -short ./...`) plus `go vet ./...`.
+- **Per plan merge:** the full command of every touched repo (Docker).
+- **Phase gate:** full suites green in summercms.go, the user plugin, sociallogin, sm-demo-app, sm-bm-app (Go + frontend) and fonoteka.go before `/msd-verify-work`. Live-provider sign-in is human UAT.
+
+### Wave 0 Gaps
+- [ ] `sm-sociallogin-plugin/testmain_test.go`, `harness_test.go` (copy of the oauthserver Postgres harness, booting `golem15.user` + `golem15.sociallogin`, memory cooler)
+- [ ] fake `classes.Provider` test double + `http.RoundTripper` fake for the goth wrappers
+- [ ] `modules/cooler` Pull tests (memory, miniredis, docker redis)
+- [ ] user-plugin race test with two explicit transactions (Pitfall 1)
+
+## Security Domain
+
+`security_enforcement` is absent from `.planning/config.json`, so it is treated as enabled. The target is ASVS Level 1, plus the D-11 security review of the matching and state code.
+
+### Applicable ASVS Categories
+
+| ASVS Category | Applies | Standard Control |
+|---------------|---------|-----------------|
+| V2 Authentication | yes | Provider-asserted identity keyed by `(provider, provider_id)`. Email auto-link only when verified (D-11). Inactive/banned gate. Register throttle and beta gate in constant time |
+| V3 Session Management | yes | One-time 64-char codes (≈384 bits) pulled atomically, TTL 5/10 min. JWT minted by `IssueToken`. Optional httpOnly token cookie via `bouncer.TokenCookie` (D-09). No JWT in URLs |
+| V4 Access Control | yes | `action=link` needs `RequestUser` (blacklist + `tokens_valid_after`). Unlink scoped to the caller with a row lock. Linking another user's identity refused |
+| V5 Input Validation | yes | Callback allow-list (exact host, fixed path, rebuilt URL). `return_to` normaliser. Provider name from the registry only. `laravelBool` parsing. Size bounds on cookie fields |
+| V6 Cryptography | yes | stdlib AES-256-GCM, HKDF-SHA256 from the app key with a purpose label. `crypto/rand`. `subtle.ConstantTimeCompare`. Client secrets in `lagoon.Encrypted` columns |
+| V7 Errors and Logging | yes | `?error=` carries only the localized human message. Never log tokens, secrets or `RetrieveError.Body`. The framework access log never records query strings (surf/accesslog.go:44-49) |
+
+### Known Threat Patterns for this stack
+
+| Pattern | STRIDE | Standard Mitigation |
+|---------|--------|---------------------|
+| Login CSRF / callback replay (attacker's code in victim's browser) | Spoofing | Sealed per-browser state cookie, constant-time compare, empty state rejected, cookie cleared at callback (D-07) |
+| Open redirect via `frontend_callback` / `return_to` | Tampering | Exact-host allow-list, fixed path, URL rebuilt from parts; `return_to` must be a local path, with `//`, `/\` and control characters rejected |
+| Account takeover by unverified email (Facebook, Microsoft nOAuth, GitHub public email) | Elevation | D-11 table with wrapper-computed `EmailVerified`. `email_verified` carried in the pending payload, and its absence fails closed (PHP `empty(...)`) |
+| Pending-registration hijack | Elevation | Completion re-checks identity and email match. Unverified match → 422 `email_exists_link_account`, never login |
+| One-time code replay / race | Spoofing | `cooler.Pull` (GETDEL / locked invalidate), single winner test |
+| Unlink race locking a user out | DoS | `SELECT … FOR UPDATE` on `users` + guarded DELETE (measured) |
+| Beta gate bypass via pending path | Elevation | Beta key carried into the pending payload and checked at completion (Pitfall 14) |
+| Secret leakage (client secret, provider tokens) | Info disclosure | Encrypted columns, write-only settings, `json:"-"`. Pending payload holds provider tokens for ≤10 min in cache, as PHP did. Optionally seal the payload before `Set` |
+| Provider HTTP hang | DoS | `HTTPClient` with a timeout on every goth provider; `throttle:20,1` |
+| Route shadowing with oauthserver | Tampering | Reserved provider names; unknown names 404 |
+
+## Sources
+
+### Primary (HIGH confidence)
+- goth v1.82.0 source in module cache: `provider.go`, `session.go`, `user.go`, `gothic/gothic.go`, `providers/{google,facebook,github,azureadv2,microsoftonline}/*.go`
+- `golang.org/x/oauth2@v0.27.0` `token.go`, `internal/token.go`, `pkce.go`; `go-redis/v9@v9.23.0` `string_commands.go`; `otter/v2@v2.3.0` `cache_impl.go`; `miniredis/v2@v2.40.0`
+- Measurements this session: `go list -deps`, `go list -m all` diff, demo binary size, `go version -m`, govulncheck, the Postgres race probe, the ServeMux probe
+- Repo files: `summercms.go/modules/{cooler,bouncer,lagoon,surf,backpack,pact,cabana,wire}`; sm-user-plugin (fonoteka checkout) models/controllers/classes/updates/plugin.go; `sm-oauthserver-plugin` go.mod/go.work/plugin.go/harness_test.go/testmain_test.go/routes.go; `sm-pgstripe-plugin/models/settings.go`; `sm-herald-plugin/boot.go`; sm-demo-app and sm-bm-app config/summer.yaml/go.work/frontend
+- PHP: wavepath `plugins/golem15/user/{components/SocialAuth.php, controllers/ApiController.php, routes.php, models/User.php, lang/en|pl/lang.php, tests/…}`; fonoteka `OAuthIdentityApiController.php`; Horoskopia `app/stores/auth.ts`, `pages/auth/oauth/*.vue`
+- Google discovery doc `https://www.googleapis.com/discovery/v1/apis/oauth2/v2/rest` (Userinfo `verified_email`)
+
+### Secondary (MEDIUM confidence)
+- RFC 9700 §2.1, §2.1.1 (rfc-editor.org/rfc/rfc9700.html), PKCE/state guidance
+- github.com/markbates/goth repo page (stars, license, maintainer-search link)
+
+### Tertiary (LOW confidence)
+- Third-party nOAuth write-ups via web search (Cayosoft, Better Auth docs, a Microsoft Q&A quote): used only as context for the already-locked D-11
+
+## Metadata
+
+**Confidence breakdown:**
+- Standard stack: HIGH. Version from the proxy, imports and size measured, govulncheck run.
+- Architecture: HIGH for Go integration points (all read), MEDIUM for the bm adoption details (Open Question 4).
+- Pitfalls: HIGH. Pitfall 1 measured, goth behaviours read from source, Google field from the official discovery doc.
+- PHP contract: HIGH. Read in full this session.
+
+**Research date:** 2026-10-19
+**Valid until:** 2026-11-18 (goth is slow-moving; recheck the user-plugin history state before planning, since 19.4/19.6 sessions are active)
diff --git a/.planning/phases/19.5-social-login-in-sm-user-plugin/19.5-VALIDATION.md b/.planning/phases/19.5-social-login-in-sm-user-plugin/19.5-VALIDATION.md
new file mode 100644
index 00000000..5d0b630f
--- /dev/null
+++ b/.planning/phases/19.5-social-login-in-sm-user-plugin/19.5-VALIDATION.md
@@ -0,0 +1,94 @@
+---
+phase: "19.5"
+slug: "social-login-in-sm-user-plugin"
+# status lifecycle: draft (seeded by plan-phase) → validated (set by validate-phase §6)
+# audit-milestone §5.5 distinguishes NOT-VALIDATED (draft) from PARTIAL (validated + nyquist_compliant: false) (#2117)
+status: draft
+nyquist_compliant: false
+wave_0_complete: false
+created: "2026-10-19"
+---
+
+# Phase 19.5 — Validation Strategy
+
+> Per-phase validation contract for feedback sampling during execution.
+
+---
+
+## Test Infrastructure
+
+| Property | Value |
+|----------|-------|
+| **Framework** | Go `testing` + `httptest`; testcontainers-go Postgres (skips under `-short`); miniredis and `redis:7-alpine` for cooler; vitest for the bm frontend |
+| **Config file** | none (Go). New `sm-sociallogin-plugin/testmain_test.go` + `harness_test.go`, copied from `sm-oauthserver-plugin` |
+| **Quick run command** | `go vet ./... && go test -short ./...` in the touched repo |
+| **Full suite command** | `go vet ./... && go test ./...` in summercms.go, the user plugin checkout, sm-sociallogin-plugin, sm-demo-app, sm-bm-app (+ `npm --prefix frontend run test && npm --prefix frontend run typecheck`) and fonoteka.go |
+| **Estimated runtime** | ~30 seconds quick; ~5 minutes full (Docker) |
+
+---
+
+## Sampling Rate
+
+- **After every task commit:** Run the quick command in the touched repo
+- **After every plan wave:** Run the full command in every touched repo
+- **Before `/msd-verify-work`:** Full suite must be green in all six repos
+- **Max feedback latency:** 60 seconds
+
+---
+
+## Per-Task Verification Map
+
+Filled in by the planner and executor. Decision → test map from RESEARCH.md § Validation Architecture:
+
+| Item | Behavior | Test Type | Automated Command | File Exists | Status |
+|------|----------|-----------|-------------------|-------------|--------|
+| D-08 | `Pull` returns and removes once; expired → not found; N concurrent pullers → one winner (memory, miniredis, Redis) | unit + docker | `go test ./modules/cooler -run 'TestPull'` | ❌ W0 | ⬜ pending |
+| D-13 | password user unlinks last identity → 204; password-less last → 409; two concurrent unlinks → one survives | integration | user plugin `go test ./... -run 'TestOAuthIdentitiesDestroy'` | ⚠ update existing | ⬜ pending |
+| D-15 | no sociallogin → `{"success":true,"providers":[]}`; enabled fakes → `[{name,label}]`; disabled excluded | unit | `-run 'TestOAuthProviders|TestProvidersList'` | ❌ | ⬜ pending |
+| D-03 | fake provider drives redirect → callback → code → complete end to end | integration | sociallogin `-run TestFlow` | ❌ | ⬜ pending |
+| D-04 | unknown/reserved name → 404; disabled → `?error=`; registry rejects reserved names | unit | `-run 'TestRoutes|TestRegistry'` | ❌ | ⬜ pending |
+| D-07 | missing/tampered/expired/mismatched state → `invalid_state`; cookie cleared; attributes | unit | `-run TestStateCookie` | ❌ | ⬜ pending |
+| D-06/D-17 | callback and `return_to` normalisation tables | unit | `-run TestNormalize` | ❌ | ⬜ pending |
+| D-09 | complete 422/410/200/second-use 410; cookie mode sets token cookie | integration | `-run TestOAuthComplete` | ❌ | ⬜ pending |
+| D-09 | register-complete 422 consent keeps code; 410; `email_exists_link_account`; concurrent submit → one 200 | integration | `-run TestRegisterComplete` | ❌ | ⬜ pending |
+| D-10 | matching branch table | integration | `-run TestMatching` | ❌ | ⬜ pending |
+| D-11 | email-verified per provider, nOAuth case | unit + integration | `-run 'TestEmailVerified|TestNOAuth'` | ❌ | ⬜ pending |
+| D-12 | beta gate off/wrong/pending path | integration | `-run TestBetaGate` | ❌ | ⬜ pending |
+| D-14 | link via bearer/`token`/`auth_token`; no JWT; owned by another user | integration | `-run TestLink` | ❌ | ⬜ pending |
+| D-16 | settings override, secret never echoed, enabled iff id+secret, live change | integration | `-run TestSettings` | ❌ | ⬜ pending |
+| D-19 | provider tokens encrypted with `token_expires_at` | integration | `-run TestIdentityTokens` | ❌ | ⬜ pending |
+| D-20 | route middleware throttles | unit | `-run TestRouteMiddleware` | ❌ | ⬜ pending |
+| PHP map | one Go test per PHP social-auth test case | integration | `-run TestPHP` | ❌ | ⬜ pending |
+| D-21 | demo and bm boot with sociallogin; bm `/oauth` reserved from SPA fallback; fonoteka green at new pin | app tests | per-app full commands | ⚠ update existing | ⬜ pending |
+
+*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
+
+---
+
+## Wave 0 Requirements
+
+- [ ] `sm-sociallogin-plugin/testmain_test.go`, `harness_test.go` (Postgres harness booting `golem15.user` + `golem15.sociallogin`, memory cooler)
+- [ ] Fake `Provider` test double + `http.RoundTripper` fake for the goth wrappers
+- [ ] `modules/cooler` Pull tests (memory, miniredis, docker Redis)
+- [ ] User plugin unlink race test with two explicit transactions
+
+---
+
+## Manual-Only Verifications
+
+| Behavior | Requirement | Why Manual | Test Instructions |
+|----------|-------------|------------|-------------------|
+| Live sign-in with Google, Facebook, GitHub and Microsoft | D-04, D-21 | Needs real provider app registrations and secrets | Configure credentials in sm-demo-app (and Entra in sm-bm-app), sign in through the SPA, confirm the callback lands and the identity is listed |
+
+---
+
+## Validation Sign-Off
+
+- [ ] All tasks have `` verify or Wave 0 dependencies
+- [ ] Sampling continuity: no 3 consecutive tasks without automated verify
+- [ ] Wave 0 covers all MISSING references
+- [ ] No watch-mode flags
+- [ ] Feedback latency < 60s
+- [ ] `nyquist_compliant: true` set in frontmatter
+
+**Approval:** pending