From ee22bcd139701cc03224f0aa24ff0746b5ad04a9 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Tue, 20 Oct 2026 00:24:57 +0200 Subject: [PATCH] docs(19.5): pattern map and post-research decisions --- .../19.5-CONTEXT.md | 13 + .../19.5-PATTERNS.md | 418 ++++++++++++++++++ 2 files changed, 431 insertions(+) create mode 100644 .planning/phases/19.5-social-login-in-sm-user-plugin/19.5-PATTERNS.md diff --git a/.planning/phases/19.5-social-login-in-sm-user-plugin/19.5-CONTEXT.md b/.planning/phases/19.5-social-login-in-sm-user-plugin/19.5-CONTEXT.md index 6a91c111..faca7963 100644 --- a/.planning/phases/19.5-social-login-in-sm-user-plugin/19.5-CONTEXT.md +++ b/.planning/phases/19.5-social-login-in-sm-user-plugin/19.5-CONTEXT.md @@ -75,7 +75,20 @@ Repos touched: `sm-sociallogin-plugin` (new), `sm-user-plugin`, `summercms.go` ( ### 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. +### Decisions after research (2026-10-20) +These answer RESEARCH.md's Open Questions 1, 4 and 6. +- **D-22:** **D-10 fields are mapped onto the existing schema; sm-user-plugin gets no new migrations.** PHP `username` maps to the existing `handle`. `marketing_consent` is set when the user opts in. The full consent details (terms, privacy, marketing, versions, IP) go into `RegisterEvent.Payload` so apps can record them. No default group is assigned and no `password_bootstrap_source` column is added. +- **D-23:** **Existing users of bearer-only SPAs link an identity through a one-time link ticket.** sociallogin mounts an authenticated `POST /_user/api/v1/oauth-link-ticket` that returns a 60-second code stored in `cooler`. The SPA navigates to `/oauth/{p}?action=link&link_ticket=…`. The plugin `Pull`s the ticket and stores the user id in the state cookie. This keeps JWTs out of URLs. D-11 is unchanged, so Microsoft is never treated as email-verified, even for a single tenant. The bm frontend uses the ticket to attach Microsoft. +- **D-24:** **fonoteka gets the relaxed D-13 unlink rule.** There is no opt-out flag. If a fonoteka parity or test case covers the old 409, it is updated. +- **D-25:** **The phase has 4 plans.** (1) Foundations: `cooler.Pull`, the goth decision note, the user-plugin changes D-13 (unlink with a `SELECT … FOR UPDATE` lock on the user row) and D-15, and the remaining folded-todo fix. (2) sm-sociallogin-plugin. (3) Adoption in sm-demo-app, sm-bm-app and fonoteka. (4) Unit tests and the PHP test map. + ### Claude's Discretion +- RESEARCH.md's recommendations settle the other open questions: + - Login touches last-seen and the IP address, with no new event. + - A soft-deleted account is restored only on an identity match or a verified email match. + - When Microsoft's Graph `mail` is empty, fall back to `userPrincipalName`, still treated as unverified. + - An unknown provider returns 404. A disabled provider or an invalid action returns a 302 to the callback with `?error=`. +- The framework tag that ships `Pull` is chosen at execution, and the tag and push wait for the user's go-ahead. - 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. diff --git a/.planning/phases/19.5-social-login-in-sm-user-plugin/19.5-PATTERNS.md b/.planning/phases/19.5-social-login-in-sm-user-plugin/19.5-PATTERNS.md new file mode 100644 index 00000000..fb302bfd --- /dev/null +++ b/.planning/phases/19.5-social-login-in-sm-user-plugin/19.5-PATTERNS.md @@ -0,0 +1,418 @@ +# Phase 19.5: Social login in sm-user-plugin - Pattern Map + +**Mapped:** 2026-10-19 +**Files analyzed:** 27 (new or modified, across 5 repos) +**Analogs found:** 25 / 27 + +All paths below are relative to `/media/nvme/dev/golem15/summercms.io/summercms/`. sm-user-plugin is edited in its fonoteka checkout `fonoteka.go/plugins/golem15/user` (a submodule; run git from inside it). + +**Correction to RESEARCH Q5 / Pitfall 13:** the fonoteka checkout of sm-user-plugin is now at `4c5d3b8 Merge remote-tracking branch 'origin/master'` and `git status -sb` reports `## master...origin/master` (in sync). The diverged history is already merged and pushed. `plugin.go:208-209` holds both `console.RequirePasswordChange(p.withDB)` and `console.BackfillHandles(p.app)`, and `withDB` sits at `plugin.go:212-227`. The folded todo is therefore mostly done. What remains is giving `BackfillHandles` the `withDB` helper as well, if it still looks up only a published DB. The sm-bm-app and sm-demo-app checkouts still record `24b16b1`, so the pointer bumps still happen. + +## File Classification + +| New/Modified File | Role | Data Flow | Closest Analog | Match Quality | +|---|---|---|---|---| +| `summercms.go/modules/cooler/cooler.go` (Cache interface + `Pull`) | interface | request-response | same file `:74-95` (Increment doc style) | exact | +| `summercms.go/modules/cooler/memory.go` (`Pull`) | service (driver) | CRUD | `memory.go:113-139` `Increment` (otter `Compute`) | exact | +| `summercms.go/modules/cooler/rediscache.go` (`Pull`) | service (driver) | CRUD | `rediscache.go:41-54` `Get` + `:94-100` `Increment` | exact | +| `summercms.go/modules/cooler/{memory,redis,docker}_test.go` | test | — | `memory_test.go:207` `TestMemoryIncrementConcurrent`, `redis_test.go:39` | exact | +| `summercms.go/modules/cooler/README.md`, `docs/services/caching.md` | docs | — | existing sections for `Increment` | exact | +| `summercms.go/.planning/notes/-sociallogin-goth.md` | decision note | — | `.planning/notes/2026-10-19-oidc-on-wristband-golang-jwt.md` | exact | +| `user/classes/oauth_providers.go` (new: `OAuthProvider`, `OAuthProviderSource`) | interface | service-lookup | `user/plugin.go:95` `app.Publish[bouncer.BlacklistStore](bl)` | role-match | +| `user/controllers/api_controller.go` (`OAuthProviders` lookup; export `registrationSettings`, `registrationsFromIP`, `refreshTTL`, `laravelBool`) | controller | request-response | same file `:216-223`, `:685`, `:708`, `:834`, `:1252` | exact | +| `user/controllers/oauth_identities.go` (atomic unlink, provider list from source) | controller | CRUD | same file `:53-101` | exact | +| `user/controllers/registration.go` (`RegisterOptions` OAuth switch) | service | CRUD | same file `:26-84` | exact | +| `user/plugin.go` (`BackfillHandles` via `withDB`) | config/plugin | — | same file `:208-227` | exact | +| `user/oauth_identities_test.go` (update `...WithPassword`, race test) | test | — | same file `:101-155`, helpers `:264-290` | exact | +| `user/README.md` | docs | — | existing API reference | exact | +| `sm-sociallogin-plugin/plugin.go` | plugin | — | `sm-oauthserver-plugin/plugin.go` | exact | +| `sm-sociallogin-plugin/routes.go` | route | request-response | `sm-oauthserver-plugin/routes.go:29-80` + `user/routes.go:9-31` | exact | +| `sm-sociallogin-plugin/config.go` (+ env copy) | config | — | `sm-oauthserver-plugin/servers.go:60-75` + `sm-herald-plugin/boot.go:47-64` | exact | +| `sm-sociallogin-plugin/config/config.yaml` | config | — | `sm-oauthserver-plugin/config/config.yaml` | exact | +| `sm-sociallogin-plugin/admin_settings.go` | config (admin) | CRUD | `sm-pgstripe-plugin/admin.go:20-42` | exact | +| `sm-sociallogin-plugin/models/settings.go` + `models/settings/fields.yaml` | model | CRUD | `sm-pgstripe-plugin/models/settings.go` + `models/settings/fields.yaml` | exact | +| `sm-sociallogin-plugin/updates/*_create_golem15_sociallogin_settings.go` + `registry.go` | migration | — | `sm-pgstripe-plugin/updates/202610130001_create_golem15_pgstripe_settings.go` | exact | +| `sm-sociallogin-plugin/classes/provider.go` (Provider, Profile, Registry) | service | — | none in repo; RESEARCH Pattern 1 | no analog | +| `sm-sociallogin-plugin/classes/providers_goth.go` | service | request-response (outbound HTTP) | none; RESEARCH Code Example 1 | no analog | +| `sm-sociallogin-plugin/classes/statecookie.go` | utility | transform | `summercms.go/modules/lagoon/encrypted.go:45-49` (HKDF), `bouncer/cookie.go:145-159` (cookie shape) | partial | +| `sm-sociallogin-plugin/classes/{callback,matcher,codes}.go` | service | CRUD | `user/controllers/registration.go`, `user/classes/session_user.go` | role-match | +| `sm-sociallogin-plugin/controllers/*.go` | controller | request-response | `sm-oauthserver-plugin/controllers/authorizations.go:35-125` | exact | +| `sm-sociallogin-plugin/{testmain,harness}_test.go` + tests | test | — | `sm-oauthserver-plugin/testmain_test.go`, `harness_test.go` | exact | +| `sm-sociallogin-plugin/go.mod`, `go.work`, `README.md`, `lang/{en,pl}/lang.yaml` | config | — | `sm-oauthserver-plugin/{go.mod,go.work,README.md,lang/}` | exact | +| `sm-demo-app/{summer.yaml,go.work,.gitmodules,boot_test.go,config/*}` | config | — | the `golem15.oauthserver` entries in the same files | exact | +| `sm-bm-app/{summer.yaml,go.work,.gitmodules,config/*}` | config | — | the `golem15.user` entries | exact | +| `sm-bm-app/plugins/jz/bm/static.go` (add `/oauth`) | config | — | same file `:42` (inside the **sm-bm-plugin submodule**) | exact | +| `sm-bm-app/frontend/src/{router.ts,stores/auth.ts,api/user.ts,views/LoginView.vue}` + new callback/consent views | component/store | request-response | `router.ts:10-11`, `stores/auth.ts:66-78`, `api/user.ts:12-13` | role-match | + +## Pattern Assignments + +### `summercms.go/modules/cooler/cooler.go` (interface) + +Add `Pull` next to `Get` in the `Cache` interface (`cooler.go:74-95`), with the doc style used there: +```go + // Get returns the value at key and whether it was found. + Get(ctx context.Context, key string) ([]byte, bool, error) + ... + // Increment adds by to the integer at key and returns the new value. A + // missing key starts at 0 and gets no TTL; an existing key keeps its + // expiry, as Redis INCRBY does. + Increment(ctx context.Context, key string, by int64) (int64, error) +``` +Suggested: `// Pull returns the value at key and removes it in one atomic step, as Laravel's Cache::pull. Of two concurrent callers at most one gets found=true.` `Pull(ctx context.Context, key string) ([]byte, bool, error)`. + +### `summercms.go/modules/cooler/memory.go` (`Pull`) + +**Analog:** `Increment`, `memory.go:113-139`. Same otter per-key lock; return `otter.InvalidateOp`: +```go + now := time.Now() + m.c.Compute(m.key(key), func(old memEntry, found bool) (memEntry, otter.ComputeOp) { + cur := int64(0) + next := memEntry{} + if found && !old.expired(now) { + ... + } + ... + return next, otter.WriteOp + }) +``` +Expiry check and `clone` come from `lookup`/`Get` (`memory.go:67-85`): `if e.expired(time.Now()) {...}`, `return clone(e.val), true, nil`. + +### `summercms.go/modules/cooler/rediscache.go` (`Pull`) + +**Analog:** `Get` `:41-54` (copy `ready()` guard and the `redis.Nil` mapping), swapping `Get` for `GetDel`: +```go + if err := r.ready(); err != nil { + return nil, false, err + } + b, err := r.client.Get(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 +``` + +**Tests:** add `Pull` cases to `TestMemoryCache` (`memory_test.go:27`) and `TestRedisCache` (`redis_test.go:39`, miniredis). Model a concurrency test on `TestMemoryIncrementConcurrent` (`memory_test.go:207`): N goroutines `Pull` one key, exactly one gets it. + +--- + +### `user/controllers/api_controller.go` (`OAuthProviders`) + +**Current stub** (`:216-223`): +```go +// OAuthProviders reports that this app configures no social providers. +func OAuthProviders(app *backpack.App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, map[string]any{ + "success": true, + "providers": wire.Slice([]any{}), + }) + } +} +``` +Look up the source **per request** inside the closure: `src, ok := app.Lookup[classes.OAuthProviderSource]()`. Without a source, keep the exact empty-slice body. With one, return `[{name,label}]` through `wire.Slice`. Response writer: `writeJSON` (`:1026-1029`, sets `Cache-Control: no-cache, private`). + +**Helpers to export additively** (keep the unexported names as thin wrappers so internal callers don't churn): +- `registrationSettings(app) (allow, throttle bool, mode string, require bool)` `:685` +- `registrationsFromIP(ctx, gdb, ip) int64` `:708` +- `refreshTTL(app) time.Duration` `:834-840` (config `golem15.user.jwt.refresh_ttl`, default 20160 min) +- `laravelBool(v any) bool` `:1252-1265` (accepts `"1"`, `"true"`; RESEARCH asks to accept `"on"`/`"yes"` too; do that in sociallogin, not by changing this function's behaviour) + +### `user/classes/oauth_providers.go` (new interface) + +**Publish analog:** `user/plugin.go:95` publishes under an interface with an explicit type argument: +```go + if err := app.Publish[bouncer.BlacklistStore](bl); err != nil { + return err + } +``` +sociallogin does `app.Publish[userclasses.OAuthProviderSource](svc)`; a bare `app.Publish(svc)` would key on the concrete type and the lookup would miss (RESEARCH Pitfall 7). + +### `user/controllers/oauth_identities.go` (atomic unlink) + +**Current body** (`:53-101`): `First` row (404 via `writeOAuthIdentityNotFound`), `Count`, `if n == 1 { writeJSON(w, http.StatusConflict, map[string]any{"error": oauthLastMethodBlocked(r, app)}) }`, `Delete`, `204`. Keep: the allow-list check, the 404 path, the 409 body, `writeOpaque500`. Replace the count+delete with RESEARCH Code Example 5 (transaction, `SELECT id FROM users WHERE id = ? FOR UPDATE`, guarded DELETE, `RowsAffected == 0` → 409). The default list `[]string{"google", "facebook", "github"}` (`:57`) gets `"microsoft"` or falls back to `OAuthProviderSource.KnownOAuthProviders()`. + +### `user/controllers/registration.go` (`RegisterOptions`) + +```go +type RegisterOptions struct { + Activate bool + IP string +} +``` +`RegisterUser` always does `user.HasSelfSetPassword = true` (`:71`) and calls `classes.AssignHandle` (`:80`). Add an additive field (e.g. `SelfSetPassword *bool` or `OAuthBootstrap bool`) that sets `HasSelfSetPassword=false`; the zero value keeps today's behaviour. Validation goes through `models.User{}.Rules()` (requires `password` + `password_confirmation`), so sociallogin passes a random password in both fields. Events: `FireRegisterEvent(ctx, app, user, fields)` `:90`, `FireCreatedEvent` `:106`; token `IssueToken(app, user, r)` `:136`; payload `APIArray(ctx, app, user)` `:149`. + +### `user/plugin.go` (folded todo) + +```go +func (p *Plugin) Commands() []bonfire.Command { + return append([]bonfire.Command{console.RequirePasswordChange(p.withDB), console.BackfillHandles(p.app)}, console.APITokenCommands(p.app, p.withDB)...) +} + +func (p *Plugin) withDB(ctx context.Context, fn func(*gorm.DB) error) error { + ... + if gdb, ok := p.app.Lookup[*gorm.DB](); ok && gdb != nil { + return fn(gdb) + } + sqlDB, gdb, err := lagoon.OpenFromApp(ctx, p.app) + if err != nil { + return err + } + defer sqlDB.Close() + return fn(gdb) +} +``` +Only `BackfillHandles` is left to switch to `p.withDB`. + +### `user/oauth_identities_test.go` + +Tests to update or copy: `TestOAuthIdentitiesDestroyLastMethodBlockedWithPassword` (`:131`, flips to 204 under D-13), `TestOAuthIdentitiesDestroyLastMethodBlockedENAndPL` (`:101`, keep with `has_self_set_password=false`). Helpers: `seedOAuthIdentity(t, db, userID, provider, providerID)` `:271`, `countOAuthIdentities` `:285`, `activateOAuthIdentitiesLang` `:264`; from `session_test.go`: `sessionApp` `:446`, `insertUser` `:382`, `loginToken` `:364`, `bearer` `:405`, `postJSON` `:395`. + +--- + +### `sm-sociallogin-plugin/plugin.go` + +**Analog:** `sm-oauthserver-plugin/plugin.go` (whole file, 130 lines). Copy: +```go +var ( + _ party.Plugin = (*Plugin)(nil) + _ pact.HasConfig = (*Plugin)(nil) + _ pact.HasLang = (*Plugin)(nil) + _ pact.HasMigrations = (*Plugin)(nil) + _ pact.HasModels = (*Plugin)(nil) + _ pact.HasRoutes = (*Plugin)(nil) +) + +const PluginID = "golem15.oauthserver" // → "golem15.sociallogin" + +//go:embed config +var configFS embed.FS + +//go:embed lang +var langFS embed.FS + +func (p *Plugin) ID() string { return PluginID } +func (p *Plugin) Requires() []string { return []string{"golem15.user"} } +func (p *Plugin) Register(app *backpack.App) error { p.app = app; return nil } +func (p *Plugin) Boot(app *backpack.App) error { + p.app = app + cfg, err := LoadConfig(app) + ... + return app.Publish(servers) +} +func (p *Plugin) ConfigFS() fs.FS { return configFS } +func (p *Plugin) LangFS() fs.FS { return langFS } +func (p *Plugin) Migrations() []*gormigrate.Migration { return updates.All() } +func (p *Plugin) Models() []any { return models.All() } +func init() { party.Register(&Plugin{}) } +``` +Add `_ pact.HasSettings = (*Plugin)(nil)`. Publish the provider `*Registry` in `Register` (so dependents can add providers) and `OAuthProviderSource` in `Boot`. Boot must not need a DB or cache (sm-demo-app `boot_test.go:27` boots every plugin without a database); fetch `*gorm.DB` and `cooler.FromApp(app)` (`cooler.go:237`) per request, or use `lagoon.OnDatabase` (`lagoon/ondatabase.go:25`, used in `sm-herald-plugin/boot.go:70`). + +### `sm-sociallogin-plugin/routes.go` + +**Analogs:** `sm-oauthserver-plugin/routes.go:29-80` (guard + `GroupRaw` with redirects) and `user/routes.go:9-31` (user API group): +```go +func (p *Plugin) Routes(r pact.Router) error { + if p.servers == nil || p.servers.OIDC == nil { + return errors.New(PluginID + ": routes before boot") + } + ... + r.GroupRaw("/", surf.Use(authorize), func(g pact.Router) { + g.Get("/oauth/authorize", oidc.Authorize) + ... + }) +``` +```go + r.Group("/_user/api/v1", surf.Use("throttle:user-api"), func(g pact.Router) { + g.Get("/oauth-providers", controllers.OAuthProviders(p.app)) + ... + }) +``` +For sociallogin: `r.GroupRaw("/oauth", surf.Use("throttle:20,1"), …)` with `g.Get("/{provider}", …)` and `g.Get("/{provider}/callback", …)`; `r.Group("/_user/api/v1", surf.Use("throttle:user-api"), …)` with `g.Get("/oauth-complete", …)` and `g.Post("/oauth-register-complete", …)`. Note that oauthserver mounts `/oauth/jwks`, `/oauth/token`, `/oauth/userinfo`, `/oauth/authorize`, `/oauth/authorizations`, `/oauth/mcp/*` in sm-demo-app: provider names must not collide (RESEARCH Pitfall 9), and `/oauth/{provider}` must not shadow them. + +### `sm-sociallogin-plugin/config.go` + `config/config.yaml` + +**LoadConfig analog:** `sm-oauthserver-plugin/servers.go:60-75`: +```go +func LoadConfig(app *backpack.App) (Config, error) { + cfg := Config{ /* defaults */ } + if app == nil || app.Config == nil { + return cfg, nil + } + c := app.Config + issuer := strings.TrimSpace(c.String("golem15.oauthserver.issuer")) + if issuer == "" { + issuer = strings.TrimSpace(c.String("app.url")) + } + cfg.Issuer = strings.TrimSuffix(issuer, "/") +``` +**Legacy env mapping:** `sm-herald-plugin/boot.go:47-64`: +```go + copyEnv := func(key, envName string) { + if strings.TrimSpace(app.Config.String(key)) != "" { + return + } + if v := strings.TrimSpace(os.Getenv(envName)); v != "" { + _ = app.Config.Set(key, v) + } + } + copyEnv(ConfigVAPIDSubject, "VAPID_SUBJECT") +``` +Use for `GOOGLE_CLIENT_ID`/`_SECRET`, `FACEBOOK_*`, `GITHUB_*`, `MICROSOFT_*`, `BETATESTER_KEY`. +**config.yaml header style:** `sm-oauthserver-plugin/config/config.yaml:1-4` (comment block naming `SUMMER_GOLEM15____` and the PHP env name above each key). + +### `sm-sociallogin-plugin/admin_settings.go` + +**Analog:** `sm-pgstripe-plugin/admin.go:20-42`: +```go +//go:embed models/settings/fields.yaml +var adminFS embed.FS + +func (p *Plugin) AdminFS() fs.FS { return adminFS } + +func (p *Plugin) Settings() []pact.SettingsItem { + return []pact.SettingsItem{{ + Code: SettingsCode, + Label: models.LangPrefix + "settings.label", + Description: models.LangPrefix + "settings.description", + Category: models.LangPrefix + "settings.category", + Icon: "credit-card", + Order: 520, + Permissions: []string{PermissionAccessSettings}, + Form: "models/settings/fields.yaml", + NewModel: func() any { return &models.Settings{} }, + }} +} +``` + +### `sm-sociallogin-plugin/models/settings.go` + `fields.yaml` + +**Analog:** `sm-pgstripe-plugin/models/settings.go`. Key pieces: +```go +const ( + ConfigSecretKey = "golem15.pgstripe.secret_key" + ... +) +type Settings struct { + ID uint `gorm:"column:id;primaryKey"` + PublishableKey *string `gorm:"column:publishable_key"` + SecretKey lagoon.Encrypted `gorm:"column:secret_key;type:text" json:"-"` + ... +} +func (Settings) TableName() string { return "golem15_pgstripe_settings" } +func (Settings) Fillable() []string { ... } +func (Settings) SecretFallbacks() map[string]string { + return map[string]string{ "secret_key": ConfigSecretKey, ... } +} +func LoadSettings(ctx context.Context, db *gorm.DB) (*Settings, error) { + ... + var rows []Settings + if err := db.WithContext(ctx).Where("id = ?", 1).Limit(1).Find(&rows).Error; err != nil { ... } + if len(rows) == 0 { + return &Settings{ID: 1, CheckoutMode: CheckoutModeCheckout}, nil + } + return &rows[0], nil +} +var _ cabana.SecretFallbacks = Settings{} +func init() { Register(Settings{}) } +``` +Map: `

_client_id` plain `*string`, `

_client_secret` `lagoon.Encrypted` with `json:"-"`, `SecretFallbacks` → `golem15.sociallogin.providers.

.client_secret`. Client-id fallback to config is done in code (not covered by `SecretFallbacks`). `LoadSettings` per request gives "no restart" (D-16). +**fields.yaml:** `type: password` for secrets (pgstripe `fields.yaml:42-46`), `type: text` with `autocomplete: off` for ids, tabs per provider. + +### `sm-sociallogin-plugin/updates/*` + +**Analog:** `sm-pgstripe-plugin/updates/202610130001_create_golem15_pgstripe_settings.go` (gormigrate slice, `run(tx, []string{CREATE TABLE …, INSERT … (id) VALUES (1)})`, Rollback drops, `init() { Register(...) }`), plus `updates/registry.go` from the same plugin. + +### `sm-sociallogin-plugin/controllers/*.go` + +**Analog:** `sm-oauthserver-plugin/controllers/authorizations.go:35-125`: handler factories `func X(app *backpack.App) http.HandlerFunc`, DB via a local `appDB(app)` helper (`:161`), JSON errors `writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "server_error"})`. For oauth-complete/register-complete bodies use `wire.WriteJSON` with `Cache-Control: no-cache, private` as in `user/controllers/api_controller.go:1026-1029`. Bouncer cookie (D-09 opt-in): `bouncer.LoadCookieConfig(app.Config)` then `cfg.TokenCookie(token, userctrl.RefreshTTL(app))` (`bouncer/cookie.go:145-159`). Link user: `userclasses.RequestUser(ctx, app, r)` (`user/classes/session_user.go:34`, bearer then `token`/`auth_token` cookies). Login gating: `userclasses.RejectIfThrottled` (`user/classes/throttle.go:67`), `userclasses.TouchLastSeen` (`last_seen.go:16`). Identity rows: `usermodels.OAuthIdentity` (`user/models/oauth_identity.go`). + +### `sm-sociallogin-plugin/classes/statecookie.go` + +**Partial analogs:** key derivation `summercms.go/modules/lagoon/encrypted.go:45-49` (`hkdf.Key(sha256.New, appKey, nil, "summercms.lagoon.encrypted.v1", 32)`; use label `golem15.sociallogin.oauth-state.v1`), app key `lagoon.LoadAppKey(cfg)` (`encrypted.go:179`), cookie shape `bouncer/cookie.go:145-159` (`HttpOnly: true, Secure: c.Secure, SameSite: …`). Body per RESEARCH Code Example 2. + +### `sm-sociallogin-plugin` tests + +**Analogs:** `sm-oauthserver-plugin/testmain_test.go` (whole file: `flag.Parse()`, `if !testing.Short()`, `postgres.Run(ctx, "postgres:16-alpine", …)`, `stopPostgres`) and `harness_test.go:79-150`: +```go + gdb, err := lagoon.Use(t.Context(), sqlDB) + ... + "SUMMER_ENV=development", + "SUMMER_ADMIN__JWT__SECRET=" + adminJWTSecret, + "SUMMER_GOLEM15__USER__JWT__SECRET=" + userJWTSecret, + "SUMMER_APP__KEY=" + testAppKey(), + ... + primary, previous, err := lagoon.LoadAppKey(cfg) + if err := lagoon.PublishEncryptionKeys(app, primary, previous); err != nil { + if err := lagoon.Publish(app, sqlDB, gdb); err != nil { + plugins, err := party.Activate(app, ids) + if err := lagoon.Migrate(gdb, plugins); err != nil { +``` +Helpers to copy: `user(t)` `:153`, `sessionToken` `:169`, `browser()` (no-redirect client) `:212`, `do(...)` `:221`. Activate `[]string{"golem15.user", PluginID}` with the memory cooler. + +### `sm-sociallogin-plugin/go.mod`, `go.work` + +`sm-oauthserver-plugin/go.work`: +``` +go 1.27.0 +use . +replace git.golem15.com/golem15/summercms => ../summercms.go +replace git.golem15.com/golem15/sm-user-plugin => ../fonoteka.go/plugins/golem15/user +``` +`go.mod`: `module git.golem15.com/golem15/sm-sociallogin-plugin`, `require git.golem15.com/golem15/sm-user-plugin `, `git.golem15.com/golem15/summercms `, `github.com/markbates/goth v1.82.0` (after the decision note and human check). + +--- + +### `sm-demo-app` mounting + +Copy the oauthserver entries: `summer.yaml:32-33` (`- id: golem15.oauthserver` / `module: git.golem15.com/golem15/sm-oauthserver-plugin`), `go.work:13` (`./plugins/golem15/oauthserver`), `.gitmodules:57-59`. Add `"golem15.sociallogin"` to `wantPlugins` in `boot_test.go:17-22` in summer.yaml order. Regenerate `plugins.gen.go` with `summer build`. + +### `sm-bm-app` mounting + frontend + +- `summer.yaml` (`golem15.user` entry at lines 4-5), `.gitmodules` (`plugins/golem15/user` block) and `go.work`: add sociallogin the same way. +- `plugins/jz/bm/static.go:42` `var reservedPrefixes = []string{"/_user", "/_bm", "/_quizzes", "/_presentations", "/api", "/storage"}`: add `"/oauth"`. This file is in the **sm-bm-plugin submodule** (`git.golem15.com:jakub/sm-bm-plugin`): commit there first, then bump the pointer. +- `frontend/src/router.ts:10-11` route style: `{ path: '/logowanie', name: 'login', component: () => import('@/views/LoginView.vue'), meta: { public: true, guest: true, title: 'Logowanie' } }`. Add the callback route (configured `callback_path`) and a pending-consent route the same way. +- `frontend/src/stores/auth.ts:66-69` session pattern: `const res = await userApi.login(...)`, `setSession(res.token, res.user, remember)`. The OAuth completion calls `oauth-complete?code=` and ends in `setSession` (bm stores the token in localStorage, not a cookie). +- `frontend/src/api/user.ts:12-13`: `client.request<{ token: string; user: User }>(\`${base}/login\`, { method: 'POST', body: c, auth: false })`. Add `oauthComplete`, `oauthRegisterComplete`, `oauthProviders` in the same style. +- `views/LoginView.vue:38-54` (form + `btn primary block` button): add the Microsoft button as a top-level navigation to `/oauth/microsoft?mode=spa&action=login&frontend_callback=…`. + +## Shared Patterns + +### Handler factory and JSON writing +**Source:** `user/controllers/api_controller.go:1026-1029`, `sm-oauthserver-plugin/controllers/authorizations.go:35` +**Apply to:** all sociallogin and user-plugin handlers +```go +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Cache-Control", "no-cache, private") + wire.WriteJSON(w, status, v) +} +``` + +### Service registry +**Source:** `user/plugin.go:95`; `cooler.FromApp(app)` `cooler.go:237` +**Apply to:** `OAuthProviderSource` publish/lookup, provider `*Registry`, cache access. Typed `Publish[Interface]`; lookups per request, never cached at Boot. + +### Settings with config fallback +**Source:** `sm-pgstripe-plugin/models/settings.go` (`SecretFallbacks`, `LoadSettings`) +**Apply to:** provider credentials (D-16). + +### Docker-gated tests +**Source:** `sm-oauthserver-plugin/testmain_test.go:27-42` +**Apply to:** every DB-backed test in sociallogin and the user-plugin race test. + +## No Analog Found + +| File | Role | Data Flow | Reason | +|---|---|---|---| +| `sm-sociallogin-plugin/classes/provider.go` | service (registry) | — | No pluggable provider registry exists; use RESEARCH Pattern 1 | +| `sm-sociallogin-plugin/classes/providers_goth.go` | service | outbound HTTP | goth is a new dependency; use RESEARCH Code Example 1 and Q3 | + +`statecookie.go` has only partial analogs (HKDF in lagoon, cookie shape in bouncer); the AEAD body comes from RESEARCH Code Example 2. + +## Metadata + +**Analog search scope:** `summercms.go/modules/{cooler,bouncer,lagoon}`, `fonoteka.go/plugins/golem15/user`, `sm-oauthserver-plugin`, `sm-pgstripe-plugin`, `sm-herald-plugin`, `sm-demo-app`, `sm-bm-app` (incl. `frontend/` and `plugins/jz/bm`) +**Files scanned:** ~30 +**Pattern extraction date:** 2026-10-19