[&reason=][&provider=][&email=]`.
+ - Errors redirect to `?error=`. This is a message, not a code, as in PHP.
+ - `return_to` must start with `/` and not `//`, otherwise it becomes `/`.
+- **D-07:** **OAuth state and context live in one encrypted, short-lived cookie.** The cookie holds the state, `action`, `frontend_callback`, `return_to`, consent fields with their timestamp, and goth's marshalled session. It is encrypted with the app key, httpOnly, `SameSite=Lax` (the provider's return is a top-level GET), scoped to path `/oauth`, and lasts about 10 minutes. It is cleared on callback. The callback rejects a missing or mismatched state with PHP's `oauth.invalid_state` message path. This binds the flow to the browser, as PHP's session did (blocks login CSRF), and works on any instance without Redis.
+- **D-08:** **Add an atomic `Pull` (get-and-delete) to `cooler.Cache`** in `summercms.go`: Redis `GETDEL` and a locked delete in the memory driver. It is additive, ships in a framework minor release, and the plugin pins that release. Both one-time codes use it:
+ - `oauth-complete:{code}`: 64 random chars, TTL 5 min, payload `{token, user, action, return_to}`.
+ - `oauth-pending-registration:{code}`: TTL 10 min, with the PHP fields (`provider`, `provider_id`, `token`, `refresh_token`, `expires_in`, `name`, `email`, `avatar`, `return_to`, `email_verified`, `reason`).
+
+ The pending code is pulled atomically on a successful `oauth-register-complete`, which is stricter than PHP's get+forget. A validation 422 must not consume it, so the user can fix the terms checkboxes and retry. The planner decides how (for example, validate before pulling). — **Reversibility:** costly — a published `cooler` API method.
+- **D-09:** **Completion response.** By default, `GET /_user/api/v1/oauth-complete?code=` returns 200 `{token, user, action, return_to}` as in PHP, 422 on an empty code, and 410 on a missing or used code. An **opt-in config flag** sets bouncer's `token` cookie (19.3 `CookieConfig.TokenCookie`) and leaves `token` out of the body, so cookie-based apps like wavepath need no wrapper route. 19.3 D-01 holds, because the app opts in. `POST oauth-register-complete` keeps PHP's contract: `pending_code`, `terms_accepted`, `privacy_accepted`, `marketing_consent`; 422 `{error, errors:{terms_accepted,privacy_accepted}}`; 410 for a bad code; 422 `email_exists_link_account` for an unverified email match. The `/_user/api/v1/oauth-complete` and `oauth-register-complete` paths are mounted by sociallogin, because the user plugin has no route there today.
+
+### Matching, registration and identities
+- **D-10:** **Account matching is ported from PHP verbatim** (`SocialAuth::handleOAuthLogin`, `completePendingRegistration`):
+ 1. An existing `(provider, provider_id)` identity logs that user in.
+ 2. No email → pending `reason=fb_no_email`.
+ 3. An email that matches an existing user auto-links only if the provider asserts the email is verified, otherwise pending `reason=unverified_match`.
+ 4. No match → pending (SPA).
+ 5. Before login: the user is activated and not banned.
+
+ Registration checks `allow_registration`, the register throttle, and terms and privacy consent (and `parental_authority` if sent) at most **600 s** old. A new user gets `is_activated=true`, a random password, `has_self_set_password=false`, `password_bootstrap_source='oauth_registration'`, a username from the email local part plus a uniqueness counter, and the default group. Consent is recorded, plus the marketing consent audit if opted in. Events: register, then login, through the user plugin's existing `FireRegisterEvent`/`FireCreatedEvent` and login path. The avatar goes only into `profile_data.avatar`. — **Reversibility:** one-way — auto-linking by email cannot be undone for accounts it already merged.
+- **D-11:** **Which providers count as email-verified:**
+
+ | Provider | Verified? |
+ |---|---|
+ | Google | its `email_verified`/`verified_email` field (research confirms the exact field goth exposes in `RawData`) |
+ | GitHub | true, as in PHP |
+ | Facebook | false |
+ | Microsoft | **always false** |
+
+ Microsoft's `email` claim can be set by a tenant admin (the "nOAuth" account-takeover class), so a Microsoft login never auto-links to an existing account by email. It always goes through `unverified_match`. Security-relevant: the phase gets a security review of the matching and state code.
+- **D-12:** **Optional beta gate ported**: `golem15.sociallogin.beta_key` (mapped from `BETATESTER_KEY`). It is off when empty and compared in constant time on registration. It is used on beta-stage projects (WavePath, QuestStream and others).
+- **D-13:** **Unlink aligned with PHP and made atomic** in sm-user-plugin. `OAuthIdentitiesDestroy` becomes one guarded DELETE that succeeds when `has_self_set_password` is true **or** the user has more than one identity (PHP `User.php:913`). Today's count-then-delete always blocks the last identity, even with a password. The existing test `TestOAuthIdentitiesDestroyLastMethodBlockedWithPassword` is updated to the PHP rule. This is a small additive behaviour relaxation; the status codes and body shape stay the same.
+- **D-14:** **Identity list/unlink stay host-mounted** as today: sm-user-plugin exports the handlers and apps choose the path (fonoteka `plugins/golem15/fonoteka/routes.go:240-246`, wavepath's `DELETE /_wave/api/v1/auth/profile/oauth/{provider}`). `action=link` is in sociallogin and needs an authenticated user. As in PHP `:779`, it is resolved from the bearer JWT or the `token`/`auth_token` cookie. Linking an identity already owned by another user is refused.
+- **D-15:** **`GET /_user/api/v1/oauth-providers` stays owned by sm-user-plugin.** It gets the provider list from a service that sociallogin publishes in the app's service registry (`app.Lookup`). With no sociallogin plugin it still returns `{"success":true,"providers":[]}`, so the path and payload don't change. Fonoteka's Nuxt store (`vue-fonoteka-app/app/stores/auth.ts:788`) and its parity corpus (`fonoteka.go/parity/check_corpus.go:37`) depend on it. With sociallogin, the response is `{success:true, providers:[{name,label}]}` for enabled providers only.
+
+### Configuration
+- **D-16:** **Credentials come from config and env, with an admin Settings override**, as in PHP (`Plugin.php:378`). The config keys are `golem15.sociallogin.providers..client_id|client_secret` (+ `tenant` for microsoft, + optional `redirect`), mapped from `GOOGLE_CLIENT_ID/SECRET`, `FACEBOOK_*`, `GITHUB_*` and `MICROSOFT_*`. sociallogin has a `pact.HasSettings` screen whose `
_client_id/secret` values override config. The secret is stored encrypted, is never echoed back in full to the admin form and is never logged. A provider is **enabled when its id and secret are both non-empty** after the override. Settings changes take effect without a restart.
+- **D-17:** **`frontend_callback` validation uses PHP's rules plus config extras.** The path must equal the configured callback path (default `/auth/oauth/callback`). A relative value is allowed. An absolute value must be http/https with an **exact** host match against: localhost, 127.0.0.1, the request host, the app URL host, the frontend URL host, and a configured extra allowed-hosts list (for split www/api domains). No wildcards.
+- **D-18:** The provider **redirect URI** defaults to the app's public URL + `/oauth/{p}/callback` (PHP: `CMS_URL`, then `APP_URL`). An optional per-provider `redirect` override mirrors PHP's `services.
.redirect`.
+- **D-19:** **Provider access and refresh tokens are stored on the identity row** with `token_expires_at`, as PHP does. The columns already exist as `lagoon.Encrypted`. This keeps parity and leaves room for later provider API use (for example Microsoft Graph in bm).
+
+### Routes and throttles
+- **D-20:** `/oauth/{provider}` and `/oauth/{provider}/callback` keep PHP's `throttle:20,1`. The `/_user/api/v1/oauth-*` routes sit under the user API's `throttle:user-api` group semantics. No JWT is required except for `action=link`.
+
+### Adoption
+- **D-21:** In this phase, **sm-demo-app** mounts sociallogin (every shared plugin goes into the demo instance), and **sm-bm-app** mounts it for Microsoft 365, with config and the frontend callback wiring wherever bm's frontend lives. fonoteka.go does not mount it, but must build and pass its tests at the bumped sm-user-plugin pin. Order of commits: framework release (cooler `Pull`) → sm-user-plugin → sm-sociallogin-plugin pushed → app pointer bumps as separate commits. Push, tag and repo creation on git.golem15.com need the user's go-ahead.
+
+### Claude's Discretion
+- Exact Go identifiers: the Provider interface shape, the registry function, the provider-list service type and the cookie name.
+- The cookie payload encoding (as long as it is encrypted with the app key and size-bounded).
+- How thin the goth wrapper is.
+- The settings screen layout (stock fields).
+- The config key spellings for the default callback URL, the callback path, extra hosts and the cookie-flag option.
+- Whether the Facebook Graph API version is pinned in config.
+- Username generation details beyond "local part + counter".
+
+### Folded Todos
+- **Let the sm-user-plugin console commands open their own database** (`.planning/todos/pending/user-plugin-commands-open-database.md`). `user:token:create|list|revoke` and `user:require-password-change` only look up a published `*gorm.DB`, so they fail in the real binary. The fix is the published-else-open-and-publish helper (pattern from fonoteka 14.2-01). The sm-bm-app checkout of sm-user-plugin has a commit `24b16b1 fix: open Postgres for one-shot console commands` that fonoteka's checkout lacks, so the planner first checks whether it already covers this and merges the two lines of sm-user-plugin history (fonoteka is at `c077029`, sm-bm-app at `24b16b1`, common base `7a6fa0e`). It fits here because 19.5 modifies sm-user-plugin and bumps its pin in both apps anyway.
+
+