docs(19.5): capture phase context

This commit is contained in:
Jakub Zych
2026-10-17 23:15:14 +02:00
parent 76d055e720
commit 9bcfa22c6e
2 changed files with 296 additions and 0 deletions

View File

@@ -0,0 +1,175 @@
# Phase 19.5: Social login in sm-user-plugin - Context
**Gathered:** 2026-10-17
**Status:** Ready for planning
<domain>
## Phase Boundary
Social sign-in for SummerCMS applications, ported from the PHP user plugin's `SocialAuth` (wavepath/Horoskopia version, which are byte-identical). It covers `/oauth/{provider}` and `/oauth/{provider}/callback`, the SPA one-time-code handoff (`oauth-complete`), the pending-registration branch (`oauth-register-complete`) and identity linking on login. It also covers the `oauth-providers` list, which today is a stub returning `[]`.
The discussion moved the delivery vehicle away from the roadmap's wording. The code lands in a **new shared plugin, `sm-sociallogin-plugin`**, which requires `sm-user-plugin`. sm-user-plugin gets only small additive hooks. `summercms.go` gets one additive `cooler` method. The provider set also grows beyond the roadmap: Google and Facebook (roadmap), plus GitHub (PHP parity) and Microsoft (for sm-bm-app).
Repos touched: `sm-sociallogin-plugin` (new), `sm-user-plugin`, `summercms.go` (cooler), `sm-demo-app`, `sm-bm-app`. fonoteka.go must keep building and passing its tests at the new sm-user-plugin pin.
</domain>
<decisions>
## Implementation Decisions
### Plugin and library
- **D-01:** Social login is a **separate plugin**: repo `sm-sociallogin-plugin`, Go plugin `golem15.sociallogin`, and `Requires()` the user plugin. An app without social login gets no goth dependency, no `/oauth/*` routes, no provider config and no extra attack surface. Each new provider ships in this repo, so the shared core plugin changes less often. This follows the 19.4 pattern (`sm-oauthserver-plugin` requires `sm-user-plugin`). Module path = repo path, mounted in apps as a git submodule at `plugins/golem15/sociallogin` (`.planning/notes/core-plugins-own-repos.md`). — **Reversibility:** costly — apps' `summer.yaml`, submodules and config keys reference the plugin once it ships.
- **D-02:** The OAuth client is **`github.com/markbates/goth` core plus its provider packages, without `gothic`**. `gothic` keeps sessions in a `gorilla/sessions` store, a second session mechanism next to bouncer, so it is not imported. The plugin keeps goth's marshalled session itself (D-07). Go links only imported packages, so `gorilla/sessions` may appear in `go.sum` but must not reach the binary. A decision note under `.planning/notes/` names goth and its pinned version before code lands (CLAUDE.md dependency rule). The researcher should report the binary-size delta (estimated 1–2 MB) and confirm which goth packages pull which transitive imports.
- **D-03:** The plugin has its **own exported `Provider` interface and a registration function**. Built-in providers wrap goth's. An application or another plugin can register an extra provider in Go without forking. goth's types should not leak through the public API where a plugin-owned type will do (the researcher and planner decide how thin the wrapper is). Unit tests register **fake providers through the same registry**, so the redirect, callback, completion and pending branches run end to end with no network.
- **D-04:** Providers: **google, facebook, github, microsoft**. The paths `/oauth/google` and `/oauth/facebook` (and `/github`) keep PHP's paths because desktop shells hard-code them. Microsoft is new: route `/oauth/microsoft`, goth's `azureadv2` provider (or whichever goth package fits Entra ID v2; research confirms). Its tenant is configurable (`common` | `organizations` | tenant ID), default `common`. The route accepts only registered, enabled provider names.
### Mode, state and handoff
- **D-05:** **SPA mode only, as in Horoskopia.** The PHP web mode (Winter session login, redirect to `/select`, `/parent/settings`, `/login`, `/register`) is QuestStream-era dead code: no Horoskopia or wavepath client uses it. When a request has no valid `frontend_callback`, the result goes to a configured default callback (for example `{frontend_url}/auth/oauth/callback`), so direct links and desktop shells still land on the SPA. `mode=spa` is accepted, and a missing or other `mode` is treated as SPA.
- **D-06:** The query contract follows the Horoskopia frontend (`vue-horoskopia-app/app/stores/auth.ts:432-456`):
- `/oauth/{p}` takes `mode`, `action=login|register|link`, `frontend_callback`, `return_to`, and on register `terms_accepted`, `privacy_accepted`, `marketing_consent`.
- The callback redirects to `{frontend_callback}?code=…` on success.
- Pending registration redirects to `?pending_registration=<code>[&reason=][&provider=][&email=]`.
- Errors redirect to `?error=<urlencoded human message>`. 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.<p>.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 `<p>_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.<p>.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.
</decisions>
<canonical_refs>
## Canonical References
**Downstream agents MUST read these before planning or implementing.**
### PHP contract (authoritative)
- `/media/nvme/dev/jz/wavepath.org/plugins/golem15/user/components/SocialAuth.php`: the full flow. Redirect `:111`; state errors `:213`; matching `:284`; web/SPA response `:843`; errors `:872`; pending `:919`; `completePendingRegistration` `:995`; `return_to` `:1185`; `frontend_callback` `:1202`; email-verified rule `:897`; link user resolution `:779`. Byte-identical to Horoskopia's copy.
- `/media/nvme/dev/jz/wavepath.org/plugins/golem15/user/controllers/ApiController.php`: `oauthProviders` `:1066`, `oauthComplete` `:1097`, `oauthRegisterComplete` `:1118`.
- `/media/nvme/dev/jz/wavepath.org/plugins/golem15/user/routes.php`: `:165-183`, `:238-250`.
- `/media/nvme/dev/jz/wavepath.org/plugins/golem15/user/models/User.php:913`: the unlink rule.
- `/media/nvme/dev/jz/wavepath.org/plugins/golem15/user/updates/v3.3.0/create_oauth_identities_table.php`: the identities schema.
- PHP tests to map case by case: `tests/unit/components/SocialAuthGateTest.php`, `tests/unit/components/OAuthLinkCookieTest.php`, `tests/security/OAuthPendingRegistrationTakeoverTest.php`, `tests/security/OAuthPasswordBootstrapTest.php` (under the wavepath user plugin), plus `plugins/golem15/wavepath/tests/feature/Auth/ProfileOAuthLinkTest.php`.
- Do **not** port from `../examples/golem15-wintercms-starter/plugins/golem15/user`: it is an older stateless, one-provider-per-user version.
### Frontend clients (define the contract)
- `/media/nvme/dev/golem15/horoskopia.eu/vue-horoskopia-app/app/stores/auth.ts`: `redirectToOAuth` `:432-456`, `completeOAuth` `:458-468`, `completePendingOAuthRegistration` `:527-551`.
- `/media/nvme/dev/golem15/horoskopia.eu/vue-horoskopia-app/app/pages/auth/oauth/callback.vue` and `pages/auth/oauth/register.vue`.
- `/media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/app/stores/auth.ts:788`: an `oauth-providers` consumer.
- Desktop shells: `/media/nvme/dev/jz/wavepath.org/tauri-wavepath-desktop/src-tauri/src/navigation.rs` and `electron-wavepath/src/window.js:86,135`. Both hard-code `/oauth/google` and `/oauth/facebook`.
### Go code to extend
- `../fonoteka.go/plugins/golem15/user/models/oauth_identity.go`, `updates/202610050001_create_oauth_identities.go`, `controllers/oauth_identities.go`, `controllers/api_controller.go:216` (the `OAuthProviders` stub), `controllers/registration.go` (`RegisterUser`, `IssueToken`, `APIArray`, `FireRegisterEvent`, `FireCreatedEvent`), `routes.go`, `oauth_identities_test.go`, `session_test.go` (test helpers).
- `modules/cooler/cooler.go:74-92`: the Cache interface that gets `Pull`.
- `modules/bouncer/cookie.go`: `TokenCookie` `:145`, `LoadCookieConfig` `:63`.
- `modules/pact/capabilities.go:567-591`: `SettingsItem` and `HasSettings`. Analog: `../sm-paymentgateway-plugin/admin_settings.go`.
### Project decisions
- `.planning/notes/core-plugins-own-repos.md`: plugin repo layout and commit order.
- `.planning/notes/wavepath-framework-asks-division.md`: A2 social login lands in 19.5.
- `.planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-CONTEXT.md`: D-01..D-03 (cookie transport) and D-06..D-09 (cooler).
- `.planning/phases/19.4-oidc-provider-on-wristband/19.4-CONTEXT.md`: D-01 and D-05 (the separate-plugin pattern that requires sm-user-plugin, additive-only changes).
- `.planning/research/STACK.md`: the dependency set. goth is not yet in it, so the decision note (D-02) adds it.
</canonical_refs>
<code_context>
## Existing Code Insights
### Reusable Assets
- `models.OAuthIdentity` and the migration already match PHP's schema: unique `(user_id, provider)` and `(provider, provider_id)`, FK cascade, encrypted tokens, `profile_data` Jsonable. sociallogin writes rows through this model, and no new identity table is needed.
- `controllers.RegisterUser`, `IssueToken`, `APIArray`, `FireRegisterEvent`/`FireCreatedEvent` and `mintFor`/`refreshTTL`: user creation and the `{token,user}` shape. sociallogin needs them exported or wrapped (an additive change in sm-user-plugin).
- `cooler.FromApp(app)` for the code cache, and `bouncer.CookieConfig` for the opt-in token cookie.
- `pact.HasSettings` plus the paymentgateway settings screen as the settings-override analog.
### Established Patterns
- Routes: `r.Group("/_user/api/v1", surf.Use("throttle:user-api"), …)`, with inline `throttle:20,1` supported by `surf/router.go:523`.
- Config: `app.Config.String("golem15.<plugin>.…")` from the plugin's `config/config.yaml`.
- Tests: package-level httptest against handler funcs with `bouncer.WithUser`, and testcontainers Postgres for the DB-backed ones (skipped under `-short`).
### Integration Points
- sm-user-plugin: the provider-list service lookup in `OAuthProviders`, the exported registration and token helpers, the atomic unlink, and the console-command DB helper (folded todo).
- summercms.go: `cooler.Cache.Pull` in both drivers, the Redis `surf.Store` untouched, and the README and docs updated.
- Apps: `summer.yaml`, the submodule at `plugins/golem15/sociallogin`, and config/env in sm-demo-app and sm-bm-app.
</code_context>
<specifics>
## Specific Ideas
- "Follow Horoskopia": its frontend is the reference client. It sends SPA mode always, with the consent fields on register and the callback at `/auth/oauth/callback`.
- The QuestStream-era `/select` redirect and web mode are explicitly not ported.
- Microsoft 365 is wanted for the bm app.
- The beta key gate is a recurring need on beta-stage projects (WavePath, QuestStream, …).
</specifics>
<deferred>
## Deferred Ideas
- Web-mode fallback (server-side cookie login without an SPA): no client uses it. Add it only if a non-SPA app appears.
- Showing linked social identities on the admin Users screen: not discussed and not in scope.
### Reviewed Todos (not folded)
- **Purge the Nuxt site cache on menu and FAQ admin saves**: already folded into 19.3.
- **Verified link between a frontend user and a backend user** (g15office `tqyvnzxx`): a prerequisite of 19.7, unrelated to social login.
- **Plugins register their frontend permission codes in Go** (g15office `hmguritm`): a prerequisite of 19.7.
- **Reload open apps on deploy** and **uploads keep client extension (stored XSS)**: unrelated, or already handled in 19.3.
</deferred>
---
*Phase: 19.5-social-login-in-sm-user-plugin*
*Context gathered: 2026-10-17*

View File

@@ -0,0 +1,121 @@
# Phase 19.5: Social login in sm-user-plugin - Discussion Log
> **Audit trail only.** Do not use as input to planning, research, or execution agents.
> Decisions are captured in CONTEXT.md — this log preserves the alternatives considered.
**Date:** 2026-10-17
**Phase:** 19.5-social-login-in-sm-user-plugin
**Areas discussed:** Provider client & placement, Web mode & state storage, Matching & registration rules, Credentials & callback config
---
## Provider client & placement
| Option | Description | Selected |
|--------|-------------|----------|
| x/oauth2 + own userinfo | Small, already in go.sum | |
| markbates/goth | Ready-made providers, Socialite-like | ✓ |
| x/oauth2 + coreos/go-oidc | Verified Google id_token | |
**User's choice:** goth. The user asked what pulling in gorilla/gothic means. After the explanation (gothic is optional, and only imported packages are linked), they chose goth without gothic.
| Option | Description | Selected |
|--------|-------------|----------|
| In sm-user-plugin | Roadmap default | |
| New framework module | Reusable, framework release | |
| Separate sm-socialusers-plugin | Opt-in, requires user plugin | ✓ |
| In sm-user-plugin, opt-in subpackage | No new repo | |
**User's choice:** Separate plugin. The user raised this to avoid pulling goth into every binary that uses the user plugin. Named **sociallogin**.
| Option | Description | Selected |
|--------|-------------|----------|
| Google + Facebook only | Roadmap | |
| Also GitHub | PHP parity | ✓ (+ Office365 for bm) |
| Option | Description | Selected |
|--------|-------------|----------|
| Exported Provider interface | Apps can add providers | ✓ |
| Internal registry only | | |
Microsoft: tenant configurable, default `common` ✓. Route `/oauth/microsoft` ✓ (alternatives `/oauth/office365`, `/oauth/azure`). Fake providers through the registry for tests ✓.
`oauth-providers` ownership: the user asked whether any app depends on the stub. Yes: fonoteka's Nuxt store and its parity corpus. Recorded: the user plugin keeps the route and asks a registered service.
---
## Web mode & state storage
| Option | Description | Selected |
|--------|-------------|----------|
| SPA + web via token cookie | | (first pick, then refined) |
| SPA mode only | | ✓ |
| Both, web mode exactly as PHP | `/select` etc. | |
**Notes:** "This /select stuff was QuestStream outdated practice. Let's follow Horoskopia." A scout showed that Horoskopia's backend is identical to wavepath's and that its frontend always uses `mode=spa`, so SPA only was confirmed.
| Option | Description | Selected |
|--------|-------------|----------|
| Encrypted short-lived cookie | Browser-bound, no Redis needed | ✓ |
| cooler keyed by state | | |
| Cookie nonce + cooler payload | | |
| Option | Description | Selected |
|--------|-------------|----------|
| Add atomic Pull to cooler | Framework minor | ✓ |
| Get + Forget in plugin | Non-atomic | |
| Option | Description | Selected |
|--------|-------------|----------|
| Body as PHP + opt-in cookie flag | | ✓ |
| Body only, apps wrap | | |
---
## Matching & registration rules
| Option | Description | Selected |
|--------|-------------|----------|
| Verbatim + Microsoft never trusted | | ✓ |
| Stricter: never auto-link by email | | |
| Option | Description | Selected |
|--------|-------------|----------|
| Align unlink with PHP, atomic | | ✓ |
| Keep Go's stricter rule | | |
| Option | Description | Selected |
|--------|-------------|----------|
| Hosts mount identity routes, as today | | ✓ |
| sociallogin mounts default paths | | |
Beta key: port as optional config ✓. "This might be useful, we used it for WavePath, QuestStream and basically any project on beta stage."
---
## Credentials & callback config
| Option | Description | Selected |
|--------|-------------|----------|
| Config + env only | | |
| Config + admin Settings override | As PHP | ✓ |
| Option | Description | Selected |
|--------|-------------|----------|
| PHP callback rules + config extras | | ✓ |
| Exactly PHP | | |
Redirect URI: app URL with per-provider override ✓. Store provider tokens as PHP does ✓.
Adoption: sm-demo-app ✓, sm-bm-app (Microsoft) ✓.
Todo folded: user-plugin console commands open their own database ✓.
## Claude's Discretion
Go identifiers, the cookie payload encoding, the thickness of the goth wrapper, the settings layout, config key spellings, Facebook Graph version pinning, and username generation details.
## Deferred Ideas
- Web-mode fallback.
- Admin view of linked identities.