diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md
index 112a7f86..eee0126e 100644
--- a/.planning/ROADMAP.md
+++ b/.planning/ROADMAP.md
@@ -1319,7 +1319,7 @@ Plans:
**Goal:** `sm-user-plugin` signs users in with Google and Facebook through `/oauth/{provider}` and its callback, with a provider registry open to more providers, the SPA one-time-code handoff and the pending-registration branch, matching the PHP user plugin's contract.
**Mode:** mvp
**Depends on:** Phase 19.3
-**Repos:** `sm-user-plugin`; `summercms.go` only if a provider client belongs in the framework
+**Repos:** `sm-sociallogin-plugin` (new), `sm-user-plugin`, `summercms.go` (`cooler.Cache.Pull`), `sm-demo-app`, `sm-bm-app` (with its `sm-bm-plugin` submodule); `fonoteka.go` builds and passes at the new `sm-user-plugin` pin (CONTEXT D-01, D-21)
**Requirements**: TBD
**Success Criteria** (what must be TRUE):
@@ -1328,10 +1328,20 @@ Plans:
3. Identities are linked on login and listed and unlinked through the existing identity routes. Both applications that consume the plugin still build and pass their tests.
4. The new code has unit tests, delivered in the phase's last plan.
-**Plans:** 0 plans
+**Plans:** 4 plans
Plans:
-- [ ] TBD (run /msd-plan-phase 19.5 to break down)
+**Wave 1**
+- [ ] 19.5-01-PLAN.md — Foundations: goth decision note, `cooler.Cache.Pull` with README and docs, sm-user-plugin hooks (provider-list source, row-locked PHP unlink rule, sign-in helpers, user:backfill-handles opens its DB), human goth package check (wave 1)
+
+**Wave 2** *(blocked on Wave 1 completion)*
+- [ ] 19.5-02-PLAN.md — New repo sm-sociallogin-plugin: fake-provider tracer, PHP matching and pending registration, link ticket and request validation, Google/Facebook/GitHub/Microsoft through goth with a settings override, release go-ahead and pushes in D-21 order (wave 2)
+
+**Wave 3** *(blocked on Wave 2 completion)*
+- [ ] 19.5-03-PLAN.md — Adoption: sm-demo-app mount next to the OIDC provider, sm-bm-app Microsoft 365 (backend, `/oauth` reserved, frontend callback, consent and link views), fonoteka.go green at the bumped user-plugin pin (wave 3)
+
+**Wave 4** *(blocked on Wave 3 completion)*
+- [ ] 19.5-04-PLAN.md — Unit tests for every decision, PHP social-auth test map, security review, scripts/check-phase19.5.sh (wave 4)
### Phase 19.6: Two-factor auth and device flow in sm-user-plugin (INSERTED)
@@ -1469,7 +1479,7 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 →
| 19.1. Chat satellites | 6/6 | Complete | 2026-10-18 |
| 19.3. Framework transport and infrastructure for WavePath | 6/6 | In Progress| |
| 19.4. OIDC provider on wristband | 4/4 | Complete | 2026-10-20 |
-| 19.5. Social login in sm-user-plugin | 0/TBD | Not started | - |
+| 19.5. Social login in sm-user-plugin | 0/4 | Planned | - |
| 19.6. Two-factor auth and device flow in sm-user-plugin | 0/TBD | Not started | - |
| 19.7. Generic MCP module | 0/TBD | Not started | - |
| 20. Demo instance with every shared plugin | 0/TBD | Not started | - |
diff --git a/.planning/STATE.md b/.planning/STATE.md
index 2b7e9b8d..fab99742 100644
--- a/.planning/STATE.md
+++ b/.planning/STATE.md
@@ -1,18 +1,18 @@
---
msd_state_version: "1.0"
milestone: v1.0
-current_phase: "19.3"
-current_phase_name: Framework transport and infrastructure for WavePath
+current_phase: "19.5"
+current_phase_name: Social login in sm-user-plugin
status: Ready for verification — 6/6 plans executed, UAT in progress (19.3-UAT.md)
stopped_at: Phase 19.4 complete, ready to plan Phase 19.3
-last_updated: "2026-10-19T23:07:03.019Z"
+last_updated: "2026-10-19T23:07:56.637Z"
last_activity: 2026-10-20
last_activity_desc: "Completed quick task 261020-13e: admin image preview fallback"
-state_head: 1ab7db428ace109f25c859e3f8a15dc0bcb9b00f
+state_head: 2eecfb8197d3c345ac6d7a07b6fec0b5e0028a7b
progress:
total_phases: 42
completed_phases: 35
- total_plans: 211
+ total_plans: 212
completed_plans: 208
gsd_state_version: "1.0"
milestone_name: milestone
@@ -29,7 +29,7 @@ See: .planning/PROJECT.md (updated 2026-10-12)
## Current Position
-Phase: 19.3 — Framework transport and infrastructure for WavePath
+Phase: 19.5 (Social login in sm-user-plugin) — READY TO EXECUTE
Plan: 6 of 6
Status: Ready for verification — 6/6 plans executed, UAT in progress (19.3-UAT.md)
Last activity: 2026-10-20 - Completed quick task 261020-13e: admin image preview fallback
diff --git a/.planning/phases/19.5-social-login-in-sm-user-plugin/19.5-01-PLAN.md b/.planning/phases/19.5-social-login-in-sm-user-plugin/19.5-01-PLAN.md
new file mode 100644
index 00000000..8bdfe079
--- /dev/null
+++ b/.planning/phases/19.5-social-login-in-sm-user-plugin/19.5-01-PLAN.md
@@ -0,0 +1,319 @@
+---
+phase: 19.5-social-login-in-sm-user-plugin
+plan: 01
+type: execute
+wave: 1
+depends_on: []
+files_modified:
+ - .planning/notes/2026-10-20-sociallogin-goth.md
+ - .planning/research/STACK.md
+ - .planning/todos/pending/user-plugin-commands-open-database.md
+ - .planning/todos/done/user-plugin-commands-open-database.md
+ - modules/cooler/cooler.go
+ - modules/cooler/memory.go
+ - modules/cooler/rediscache.go
+ - modules/cooler/pull_smoke_test.go
+ - modules/cooler/README.md
+ - docs/services/caching.md
+ - ../fonoteka.go/plugins/golem15/user/classes/oauth_providers.go
+ - ../fonoteka.go/plugins/golem15/user/controllers/api_controller.go
+ - ../fonoteka.go/plugins/golem15/user/controllers/registration.go
+ - ../fonoteka.go/plugins/golem15/user/controllers/oauth_identities.go
+ - ../fonoteka.go/plugins/golem15/user/console/backfill_handles.go
+ - ../fonoteka.go/plugins/golem15/user/oauth_providers_smoke_test.go
+ - ../fonoteka.go/plugins/golem15/user/social_hooks_smoke_test.go
+ - ../fonoteka.go/plugins/golem15/user/oauth_identities_test.go
+ - ../fonoteka.go/plugins/golem15/user/console_test.go
+ - ../fonoteka.go/plugins/golem15/user/README.md
+autonomous: false
+requirements: [SC-3, D-02, D-08, D-13, D-15, D-21, D-22, D-24, D-25]
+estimate:
+ tokens: 170000
+ raw_tokens: 170000
+ tasks: 4
+ confidence: low
+must_haves:
+ truths:
+ - "D-02: a decision note under .planning/notes/ names github.com/markbates/goth v1.82.0 (core plus providers google, facebook, github, azureadv2, never gothic) with the measured import graph, the +132 KiB binary delta, the go.sum-only module additions and the blocking human package check; its commit is an ancestor of the first commit that adds cooler.Cache.Pull, and no go.mod in any repository requires goth before the human check is recorded."
+ - "D-08: cooler.Cache has Pull(ctx, key) ([]byte, bool, error) that returns a value and removes it in one atomic step: Redis GETDEL in the Redis driver, otter Compute with InvalidateOp in the memory driver; a second Pull of the same key and a Pull of an expired key report found=false; README and docs/services/caching.md describe it in the same commit."
+ - "D-15: GET /_user/api/v1/oauth-providers looks up a published classes.OAuthProviderSource on every request; with none it answers exactly the old body {\"success\":true,\"providers\":[]}; with one it answers {success:true, providers:[{name,label}]} for the source's enabled providers in the source's order."
+ - "D-13, D-24: OAuthIdentitiesDestroy runs SELECT id FROM users WHERE id = ? FOR UPDATE and then one guarded DELETE in a transaction: it succeeds when has_self_set_password is true or another identity remains, answers 409 with the unchanged last_method_blocked body otherwise, and two concurrent unlinks on a password-less two-identity user leave exactly one identity."
+ - "D-04 support: the unlink allow-list is opts.Providers when set, else the published source's KnownOAuthProviders, else google, facebook, github and microsoft."
+ - "D-10, D-22 support: sm-user-plugin exports RegistrationSettings, RegistrationsFromIP, RefreshTTL, RemoteIP and FinishLogin, and RegisterOptions.PasswordLess creates a user with has_self_set_password=false; no migration, column or group assignment is added to sm-user-plugin."
+ - "Folded todo: user:backfill-handles opens the database itself when none is published, like the other user commands (24b16b1), and the todo moves to .planning/todos/done/."
+ - "D-21, D-25: framework commits come first, then sm-user-plugin commits; fonoteka.go builds and its user and fonoteka plugin suites pass at the new user-plugin code, and sm-oauthserver-plugin still builds against it (additive change)."
+ artifacts:
+ - path: ".planning/notes/2026-10-20-sociallogin-goth.md"
+ provides: "D-02 decision note naming goth v1.82.0 and the human package check"
+ contains: "markbates/goth"
+ - path: "modules/cooler/cooler.go"
+ provides: "Cache.Pull in the interface"
+ contains: "Pull(ctx context.Context, key string) ([]byte, bool, error)"
+ - path: "modules/cooler/pull_smoke_test.go"
+ provides: "TestPullSmoke over memory and miniredis"
+ contains: "TestPullSmoke"
+ - path: "../fonoteka.go/plugins/golem15/user/classes/oauth_providers.go"
+ provides: "OAuthProvider and OAuthProviderSource"
+ contains: "type OAuthProviderSource interface"
+ - path: "../fonoteka.go/plugins/golem15/user/controllers/oauth_identities.go"
+ provides: "row-locked guarded unlink"
+ contains: "FOR UPDATE"
+ key_links:
+ - from: "../fonoteka.go/plugins/golem15/user/controllers/api_controller.go"
+ to: "../fonoteka.go/plugins/golem15/user/classes/oauth_providers.go"
+ via: "OAuthProviders looks the source up per request"
+ pattern: "Lookup\\[classes.OAuthProviderSource\\]"
+ - from: "../fonoteka.go/plugins/golem15/user/controllers/oauth_identities.go"
+ to: "users row lock"
+ via: "transaction takes the user row lock before the guarded DELETE"
+ pattern: "SELECT id FROM users WHERE id = \\? FOR UPDATE"
+ - from: "modules/cooler/rediscache.go"
+ to: "Redis GETDEL"
+ via: "redisCache.Pull calls GetDel"
+ pattern: "GetDel"
+ prohibitions:
+ - statement: "No JWT, provider token, client secret or one-time code value is ever written to a log line or an error string by the code this plan adds."
+ status: unresolved
+ verification: judgment
+ - statement: "sm-user-plugin gets no new migration, table, column, route, payload change or config key (D-22, core-plugin contract); every change is an added export or the D-13 rule relaxation with unchanged status codes and bodies."
+ status: unresolved
+ verification: judgment
+ - statement: "The framework go.mod gains no module, and no repository's go.mod requires github.com/markbates/goth before the Task 4 reply is recorded."
+ status: unresolved
+ verification: judgment
+ - statement: "Framework code, README and docs name no consuming application (neutral examples only)."
+ status: unresolved
+ verification: judgment
+ - statement: "Nothing is pushed, tagged or created on git.golem15.com in this plan."
+ status: unresolved
+ verification: judgment
+---
+
+## Phase Goal
+
+ROADMAP Goal (not in user-story format; MVP mode would take a story from `/msd mvp-phase 19.5`, which was not run, so no story is invented here): `sm-user-plugin` signs users in with Google and Facebook through `/oauth/{provider}` and its callback, with a provider registry open to more providers, the SPA one-time-code handoff and the pending-registration branch, matching the PHP user plugin's contract. CONTEXT D-01 moved the code into a new shared plugin, `sm-sociallogin-plugin`, which requires `sm-user-plugin`, and D-04 adds GitHub and Microsoft.
+
+This plan's slice (plan 1 of 4, wave 1, D-25 "Foundations"): everything the new plugin builds on, in D-21 commit order. The goth decision note lands first. Then the framework's one additive method, `cooler.Cache.Pull`, is proven on both drivers (the tracer). Then sm-user-plugin gets its small additive hooks: the provider-list source (D-15), the race-safe PHP unlink rule (D-13), sign-in helpers that plan 02 calls, and the remaining folded-todo fix. The plan ends with the blocking human package check for goth, so plan 02 can install it.
+
+## Spec-less probe fallback
+
+spec-less probe fallback skipped: phase has no requirement IDs to probe (visible skip). No SPEC.md exists and ROADMAP lists "Requirements: TBD". `must_haves` derive from the ROADMAP success criteria SC-1..SC-4 and the CONTEXT decisions D-01..D-25; `requirements` lists those IDs for traceability. Prohibitions recalled from CONTEXT and RESEARCH are listed under `must_haves.prohibitions` without descriptors.
+
+## Decisions taken in this plan (recorded assumptions and Claude's discretion)
+
+- PATTERNS.md corrects RESEARCH Q5 and Pitfall 13: the fonoteka checkout of sm-user-plugin is at `4c5d3b8` (merge of origin), in sync with `origin/master`, and `plugin.go` already passes `withDB` to the token and require-password-change commands. No history merge is needed. The folded todo's remainder is `user:backfill-handles`, which still looks up only a published `*gorm.DB` (verified at planning time, `console/backfill_handles.go`). sm-demo-app already records `4c5d3b8`; sm-bm-app still records `24b16b1` (plan 03 bumps both to this phase's commit).
+- `console.BackfillHandles(app)` keeps its exported signature. It gains the same published-else-open fallback as `Plugin.withDB` (plugin.go:212-227) inside the command, so no caller changes.
+- New exported names (Claude's discretion): `classes.OAuthProvider`, `classes.OAuthProviderSource` (methods `EnabledOAuthProviders(ctx)`, `KnownOAuthProviders()`), `controllers.RegistrationSettings`, `controllers.RegistrationsFromIP`, `controllers.RefreshTTL`, `controllers.RemoteIP`, `controllers.FinishLogin`, `RegisterOptions.PasswordLess`. The unexported originals stay as the implementation (or thin wrappers), so internal callers do not churn.
+- `FinishLogin` holds the post-authentication steps of password `Login` that a social sign-in also needs: restore a soft-deleted account and send the reactivate mail, record `last_ip_address` (PHP `touchIpAddress`), touch `last_seen`. It fires no event: Go has no login event and RESEARCH Open Question 2 recommends none (CONTEXT Claude's Discretion "Login touches last-seen and the IP address, with no new event").
+- D-13 relaxes fonoteka too (D-24): no opt-out flag. fonoteka's parity fixture for `DELETE /_fonoteka/api/v1/oauth-identities/{provider}` records a 404 (no identity), so it is unaffected; plan 03 re-checks fonoteka's whole suite at the bumped pointer.
+- D-10 is rated one-way in CONTEXT, but it is implemented in plan 02 and the user already chose it, so no `checkpoint:decision` re-asks it (planner-reversibility "gating a decision already made"). D-08 is rated costly and flagged on Task 1.
+
+## Artifacts this phase produces (this plan)
+
+- Decision note `.planning/notes/2026-10-20-sociallogin-goth.md`; a goth row in `.planning/research/STACK.md` (Supporting Libraries); the folded todo moved to `.planning/todos/done/user-plugin-commands-open-database.md`.
+- Framework: interface method `cooler.Cache.Pull(ctx context.Context, key string) ([]byte, bool, error)`; implementations `(*memoryCache).Pull`, `(*redisCache).Pull`; test `TestPullSmoke` (modules/cooler/pull_smoke_test.go); README Features and API reference rows for `cooler.Cache.Pull`; docs/services/caching.md row `Cache::pull($key)` and section "One-time values".
+- sm-user-plugin (`git.golem15.com/golem15/sm-user-plugin`):
+ - `classes.OAuthProvider` (fields `Name`, `Label`, JSON `name`, `label`), `classes.OAuthProviderSource` (`EnabledOAuthProviders(ctx context.Context) []OAuthProvider`, `KnownOAuthProviders() []string`).
+ - `controllers.RegistrationSettings(app *backpack.App) (allow, throttle bool, mode string, require bool)`, `controllers.RegistrationsFromIP(ctx context.Context, gdb *gorm.DB, ip string) int64`, `controllers.RefreshTTL(app *backpack.App) time.Duration`, `controllers.RemoteIP(r *http.Request) string`, `controllers.FinishLogin(ctx context.Context, app *backpack.App, db *gorm.DB, user *models.User, ip string) error`, field `controllers.RegisterOptions.PasswordLess bool`.
+ - Behaviour: `controllers.OAuthProviders` reads the source per request; `controllers.OAuthIdentitiesDestroy` locks the user row and runs the PHP guarded DELETE; the default unlink allow-list adds `microsoft`; `console.BackfillHandles` opens its own database.
+ - Tests: `TestOAuthProvidersSourceSmoke`, `TestSocialHooksSmoke`, `TestOAuthIdentitiesDestroyConcurrentSmoke`, `TestBackfillHandlesOpensFromConfigWhenUnpublished`; `TestOAuthIdentitiesDestroyLastMethodBlockedWithPassword` renamed to `TestOAuthIdentitiesDestroyLastIdentityWithPassword` and flipped to 204.
+
+
+Record the goth decision, give cooler an atomic get-and-delete, and give sm-user-plugin the additive hooks social sign-in needs (provider-list source, race-safe PHP unlink, sign-in helpers, the last console command that cannot open its database), then stop for the human goth package check.
+
+Purpose: D-02, D-08, D-13, D-15, D-21 (framework then user plugin), D-22 helpers, D-24, D-25 plan 1; SC-3 (unlink through the existing identity routes; fonoteka keeps building).
+Output: one planning-doc commit (note + STACK.md), one framework commit with README and docs, two or three sm-user-plugin commits with README, one planning-doc commit closing the todo, and the recorded human reply.
+
+
+
+@~/.claude/msd-core/workflows/execute-plan.md
+@~/.claude/msd-core/templates/summary.md
+
+
+
+@.planning/phases/19.5-social-login-in-sm-user-plugin/19.5-CONTEXT.md
+@.planning/phases/19.5-social-login-in-sm-user-plugin/19.5-RESEARCH.md
+@.planning/phases/19.5-social-login-in-sm-user-plugin/19.5-PATTERNS.md
+@modules/cooler/README.md
+
+Repos and concurrency: this plan writes in summercms.go and in the sm-user-plugin repository checked out at ../fonoteka.go/plugins/golem15/user (a submodule of fonoteka.go with its own git; run git there with `git -C ../fonoteka.go/plugins/golem15/user`). Never stage anything in the fonoteka.go superproject (its submodule pointers for feedback, golem and user are other sessions' in-flight changes; plan 03 handles the user pointer). Before Task 1 run `git status --porcelain -- modules/cooler docs/services .planning/notes .planning/research/STACK.md .planning/todos` here and `git -C ../fonoteka.go/plugins/golem15/user status --porcelain`; if a file this plan touches is modified by someone else, stop and report. Other sessions (19.6 two-factor) may also edit sm-user-plugin: check `git -C ../fonoteka.go/plugins/golem15/user log origin/master..master` is empty before the first commit there. No co-author tags; stage files by explicit path; planning docs and code in separate commits; a README and docs change in the same commit as the API it describes. Keep `go vet ./...` and `go test -short ./...` green at every commit. Nothing is pushed or tagged (plan 02 Task 5 asks for the release go-ahead).
+
+Docker-backed tests skip under `-short`. Run them on a Docker host; if the socket is denied although the user is in the docker group, run through `sudo -u golem` (CLAUDE.md). A Docker test that could not run is recorded as NOT RUN in the SUMMARY, never as passed.
+
+
+Existing seams (extend, do not fork):
+
+- modules/cooler/cooler.go:74-95 `Cache` interface (Get, Set, Has, Forget, Remember, Increment, Flush) with doc comments in the style to copy; `FromApp(app)` at :237. The only implementations in the workspace are `memoryCache` and `redisCache` (RESEARCH Q5).
+- modules/cooler/memory.go: `memEntry.expired(now)` :24, `lookup` :67, `Get` :79 (uses `clone`), `Increment` :113-139 (otter `m.c.Compute(m.key(key), func(old memEntry, found bool) (memEntry, otter.ComputeOp) {...})`, the per-key lock to reuse), `clone` :147.
+- modules/cooler/rediscache.go: `ready()` :34, `Get` :41-54 (copy the `redis.Nil` mapping), `key(k)` :30 (`cache:`); go-redis v9 `GetDel` (Redis >= 6.2).
+- modules/cooler/redis_test.go :39 `TestRedisCache` (miniredis start and FastForward pattern), memory_test.go :207 `TestMemoryIncrementConcurrent`.
+- sm-user-plugin (paths relative to ../fonoteka.go/plugins/golem15/user):
+ - controllers/api_controller.go: `Login` :47-125 (restore at :87-93 with `sendTemplateVars(ctx, app, mailName(ctx, user, "golem15.user::mail.reactivate"), user.Email, mailVars(displayName(user), "", ""))`, `classes.TouchLastSeen` warn at :112), `OAuthProviders` :216-223, `registrationSettings` :685, `registrationsFromIP` :708, `refreshTTL` :834, `requestURL` :1004, `remoteIP` :1015, `writeJSON` :1026, `writeUnauthorized` :1038 (`{"error": true, "message": "Unauthorized"}`).
+ - controllers/registration.go: `RegisterOptions{Activate bool; IP string}` :26, `RegisterUser` :38-84 (sets `user.HasSelfSetPassword = true` at :71, `classes.AssignHandle` at :80), `FireRegisterEvent` :90, `FireCreatedEvent` :106, `IssueToken` :136, `APIArray` :149.
+ - controllers/oauth_identities.go: `OAuthIdentitiesOptions{Providers []string; WriteNotFound func(http.ResponseWriter, *http.Request)}`, `OAuthIdentitiesDestroy` :53-101 (default list at :57, First → 404, Count, 409 `{"error": oauthLastMethodBlocked(r, app)}`, Delete, 204).
+ - plugin.go: `Commands()` :207-209, `withDB` :212-227, `app.Publish[bouncer.BlacklistStore](bl)` :95 (typed publish precedent).
+ - console/backfill_handles.go: `BackfillHandles(app *backpack.App) bonfire.Command` (looks up only a published `*gorm.DB`).
+ - tests: oauth_identities_test.go `TestOAuthIdentitiesDestroyLastMethodBlockedENAndPL` :101, `TestOAuthIdentitiesDestroyLastMethodBlockedWithPassword` :131, helpers `seedOAuthIdentity` :271, `countOAuthIdentities` :285, `activateOAuthIdentitiesLang` :264; session_test.go `sessionApp` :446, `insertUser` :382, `loginToken` :364, `bearer` :405, `captureMail` :359; console_test.go `TestConsoleWithDBOpensFromConfigWhenUnpublished` :63. The `has_self_set_password` column defaults to TRUE (updates/202609220005_extend_users.go:21).
+- RESEARCH Code Example 3 (`Pull` on both drivers) and Code Example 5 (race-safe unlink SQL); RESEARCH Pitfall 1 (the measured READ COMMITTED race).
+- Decision-note structure: .planning/notes/2026-10-19-oidc-on-wristband-golang-jwt.md (Decision, Date, Status, Context, Decision bullets, rejected alternative paragraph, Related dependencies, Consequences).
+
+
+
+
+
+
+ Task 1: Tracer: the goth decision note lands first, then a one-time code is consumed exactly once through cooler.Cache.Pull on the memory and Redis drivers, documented in the same commit
+ D-08: Pull becomes a published method of the cooler.Cache interface; once plugins call it, removing or renaming it breaks them, and any other Cache implementation must add it.
+ .planning/notes/2026-10-20-sociallogin-goth.md, .planning/research/STACK.md, modules/cooler/cooler.go, modules/cooler/memory.go, modules/cooler/rediscache.go, modules/cooler/pull_smoke_test.go, modules/cooler/README.md, docs/services/caching.md
+ .planning/notes/2026-10-19-oidc-on-wristband-golang-jwt.md (structure to copy), .planning/research/STACK.md (Supporting Libraries table, lines 28-47), 19.5-RESEARCH.md (Q1, Q2, Standard Stack, Alternatives Considered, Package Legitimacy Audit, Code Example 3, Pitfall 12), modules/cooler/cooler.go, modules/cooler/memory.go, modules/cooler/rediscache.go, modules/cooler/redis_test.go, modules/cooler/memory_test.go, modules/cooler/README.md, docs/services/caching.md
+ Planning doc first (D-02). Write `.planning/notes/2026-10-20-sociallogin-goth.md` in the structure of the 2026-10-19 note: title "Decision: social sign-in uses goth without gothic"; Date 2026-10-20; Status Accepted; Context Phase 19.5 (D-01, D-02, D-03, D-04), 19.5-RESEARCH.md Q1, Q2 and Package Legitimacy Audit. Decision bullets: `github.com/markbates/goth` pinned at v1.82.0 (published 2025-08-18, MIT, origin github.com/markbates/goth at tag v1.82.0, go 1.23.0); only the core package and `providers/google`, `providers/facebook`, `providers/github`, `providers/azureadv2` are imported, and only by `sm-sociallogin-plugin` (classes/providers_goth.go); `gothic` and `goth.UseProviders` are never used (gothic is the only goth package importing `github.com/gorilla/sessions`, a second session mechanism next to bouncer; UseProviders is a process-global map); the plugin keeps goth's marshalled session in its own sealed state cookie (D-07); the imported non-stdlib packages are exactly the list in RESEARCH Q1 (core needs only stdlib and golang.org/x/oauth2; google adds golang.org/x/oauth2/google and cloud.google.com/go/compute/metadata); `azureadv2` over `microsoftonline` because only azureadv2 takes a tenant (`common`, `organizations`, `consumers` or a tenant ID) and needs no extra module. Measurements: +132 KiB on a stripped sm-demo-app binary (47,427,847 to 47,563,015 bytes), `go version -m` lists no gorilla session module, the consuming module graph gains 19 go.sum-only entries (list them from RESEARCH Q1) with no existing version changed, govulncheck v1.9.0 reported no vulnerabilities. Maintenance risk paragraph: the repository advertises a maintainer search and the last tag is 14 months old; D-03 contains it (goth stays behind the plugin's own Provider interface, each provider package is under 300 lines, a provider can be replaced without touching the public API). Rejected alternatives: hand-written x/oauth2 plus profile calls (the user prefers a maintained library for protocol code), gorilla/securecookie (a dependency for about 30 lines of stdlib AEAD), gothic. Related dependencies: golang.org/x/oauth2 (already in the workspace graph, selected by MVS), cloud.google.com/go/compute/metadata (already present). Package legitimacy: the seam cannot check Go modules (as in 19.4), so goth is [ASSUMED] and gated by a blocking human check (plan 19.5-01 Task 4) before any `go get`. Consequences: the framework go.mod gains nothing; `go list -deps` of any binary must not list github.com/gorilla/sessions; gopkg.in/yaml.v3 appears in goth's module graph as indirect and is never imported (CLAUDE.md goccy rule); PKCE stays off because goth's providers expose no hook (RFC 9700 allows state-only for confidential clients; RESEARCH State of the Art). In .planning/research/STACK.md add a Supporting Libraries row: `github.com/markbates/goth` (core plus providers google, facebook, github, azureadv2; never gothic) | v1.82.0 | OAuth 2.0 client for social sign-in in sm-sociallogin-plugin | Decided in .planning/notes/2026-10-20-sociallogin-goth.md (Phase 19.5 D-02); plugin-only, the framework go.mod does not require it. Commit the note and STACK.md together, alone: `docs(19.5): decide goth for social sign-in`.
+
+Then the tracer (D-08). In modules/cooler/cooler.go add to `Cache`, after `Get`: a doc comment "Pull returns the value at key and removes it in one atomic step, as Laravel's Cache::pull. found is false when the key is missing or expired; of two concurrent callers at most one gets found=true. The Redis driver uses GETDEL (Redis 6.2 or newer)." and `Pull(ctx context.Context, key string) ([]byte, bool, error)`. memory.go `(*memoryCache).Pull`: one `m.c.Compute` call on `m.key(key)`; missing entry returns `otter.CancelOp`; a present entry returns `otter.InvalidateOp` (expired entries are removed too) and, when not expired at `time.Now()`, the result is `clone(old.val)` with found true; never an error. rediscache.go `(*redisCache).Pull`: `r.ready()` guard, `r.client.GetDel(ctx, r.key(key)).Bytes()`, `redis.Nil` maps to (nil, false, nil), any other error is returned. README (same commit): the Features bullet listing Cache methods gains `cooler.Cache.Pull`; a new Features bullet states the atomic get-and-delete, GETDEL on Redis 6.2 or newer, the per-key lock in memory, and that one-time values shared by several instances need the Redis driver; the API reference gains a `cooler.Cache.Pull` row; Testing names `TestPullSmoke`. docs/services/caching.md (same commit): the Laravel table gains `Cache::pull($key)` mapped to `cooler.Cache.Pull`; a new section "One-time values" (after "TTLs") explains Pull for one-time codes, the at-most-one-winner guarantee, Redis 6.2+ and that the memory driver is per process so a code written on one instance cannot be pulled on another (RESEARCH Pitfall 12). Neutral wording, no application named.
+
+Smoke test modules/cooler/pull_smoke_test.go `TestPullSmoke` (runs under -short) with subtests `memory` (cooler.NewMemoryCache) and `redis` (miniredis started as TestRedisCache does): Set k=v with no TTL then Pull returns v and true; a second Pull returns found false; Get returns found false; Set with a 50 ms TTL, advance time (memory: sleep 80 ms; miniredis: FastForward 100 ms) and Pull returns found false and no error. Commit: `feat(cooler): Cache.Pull returns and removes a value atomically`.
+
+ go vet ./... && go test -count=1 -short ./modules/cooler/... && go test -count=1 -short -run '^TestPullSmoke$' -v ./modules/cooler 2>&1 | grep -q -- '^--- PASS: TestPullSmoke' && go test -count=1 -run '^TestDocsTree$' ./cmd/summer && go run ./cmd/summer docs:build --check
+ Non-zero exit: vet or the cooler suite is red (a driver no longer satisfies Cache, or Pull misbehaves), no "--- PASS: TestPullSmoke" line in the verbose run (the smoke failed or did not run), or the docs checker reports an identifier in the README or the caching page that does not exist.
+
+
+ - `N=$(git log --format=%H --diff-filter=A -- .planning/notes/2026-10-20-sociallogin-goth.md) && L=$(git log --format=%H -S 'Pull(ctx context.Context, key string)' -- modules/cooler/cooler.go) && C=$(printf '%s\n' "$L" | tail -1) && test -n "$N" && test -n "$C" && git merge-base --is-ancestor "$N" "$C"` exits 0 (note committed before the Pull code).
+ - `grep -c 'markbates/goth' .planning/notes/2026-10-20-sociallogin-goth.md` prints at least 1, and the same file matches each of `v1.82.0`, `azureadv2`, `gothic`, `gorilla/sessions`, `132 KiB`.
+ - `grep -n 'markbates/goth' .planning/research/STACK.md | grep -c '2026-10-20-sociallogin-goth.md'` prints at least 1.
+ - `grep -c 'Pull(ctx context.Context, key string) (\[\]byte, bool, error)' modules/cooler/cooler.go` prints at least 1.
+ - `grep -c 'GetDel' modules/cooler/rediscache.go` prints at least 1 and `grep -c 'InvalidateOp' modules/cooler/memory.go` prints at least 1.
+ - `grep -c 'cooler.Cache.Pull' modules/cooler/README.md` prints at least 2 and `grep -c 'Cache::pull' docs/services/caching.md` prints at least 1.
+ - `grep -c 'markbates/goth' go.mod` prints 0.
+ - `L=$(git log --format=%H -S 'Pull(ctx context.Context, key string)' -- modules/cooler/cooler.go) && C=$(printf '%s\n' "$L" | tail -1) && F=$(git show --stat --format= "$C" -- modules/cooler/README.md docs/services/caching.md modules/cooler/cooler.go) && test "$(printf '%s\n' "$F" | grep -c '|')" -ge 3` exits 0 (code, README and docs in one commit).
+
+ The goth decision is on record before any code, and a one-time value set in cooler can be taken exactly once on either driver, with the README and the caching page describing it.
+
+
+
+ Task 2: A social-login plugin can publish its provider list to /oauth-providers and reuse the user plugin's registration, throttle, token-window, IP and post-login steps
+ D-15: classes.OAuthProviderSource and the new controller exports become part of the shared core plugin's API, implemented and called by sm-sociallogin-plugin and later plugins.
+ ../fonoteka.go/plugins/golem15/user/classes/oauth_providers.go, ../fonoteka.go/plugins/golem15/user/controllers/api_controller.go, ../fonoteka.go/plugins/golem15/user/controllers/registration.go, ../fonoteka.go/plugins/golem15/user/oauth_providers_smoke_test.go, ../fonoteka.go/plugins/golem15/user/social_hooks_smoke_test.go, ../fonoteka.go/plugins/golem15/user/README.md
+ ../fonoteka.go/plugins/golem15/user/controllers/api_controller.go (Login :47-125, OAuthProviders :216-223, registrationSettings :685, registrationsFromIP :708, refreshTTL :834, remoteIP :1015, writeJSON :1026), ../fonoteka.go/plugins/golem15/user/controllers/registration.go (RegisterOptions, RegisterUser), ../fonoteka.go/plugins/golem15/user/plugin.go (:95 typed Publish), ../fonoteka.go/plugins/golem15/user/classes/last_seen.go, ../fonoteka.go/plugins/golem15/user/session_test.go (sessionApp, insertUser, captureMail), ../fonoteka.go/plugins/golem15/user/README.md (Features, API reference, registration helper paragraph :163), 19.5-RESEARCH.md (Pattern 3, Pitfall 7, Open Questions 1-3), 19.5-PATTERNS.md (user/controllers sections)
+ Per D-15 and the D-10/D-22 helpers plan 02 needs; additive only.
+
+classes/oauth_providers.go (new): struct `OAuthProvider` with string fields `Name` and `Label` (JSON tags `name` and `label`), and `type OAuthProviderSource interface { EnabledOAuthProviders(ctx context.Context) []OAuthProvider; KnownOAuthProviders() []string }`. Doc: a social-login plugin publishes its source with an explicit type argument (`app.Publish[classes.OAuthProviderSource](src)`, as plugin.go publishes `bouncer.BlacklistStore`), because a bare Publish keys on the concrete type and the lookup misses (RESEARCH Pitfall 7); EnabledOAuthProviders returns only providers that are configured right now, in display order; KnownOAuthProviders returns every registered provider name (the unlink allow-list).
+
+controllers/api_controller.go `OAuthProviders`: inside the handler (per request, never at construction, because this plugin boots first) do `src, ok := app.Lookup[classes.OAuthProviderSource]()`. Without a source the answer stays byte-identical: `writeJSON(w, http.StatusOK, map[string]any{"success": true, "providers": wire.Slice([]any{})})`. With one, `providers` is `wire.Slice` of `{"name", "label"}` maps from `src.EnabledOAuthProviders(r.Context())` in order. Update the doc comment (no longer "configures no social providers").
+
+Exports (keep the unexported functions as the implementation or make them one-line wrappers; no behaviour change for existing callers): `RegistrationSettings(app)`, `RegistrationsFromIP(ctx, gdb, ip)`, `RefreshTTL(app)`, `RemoteIP(r)`, each with a doc comment stating its config keys and defaults (allow_registration true, use_register_throttle true; registrations from one IP in the last 60 minutes; golem15.user.jwt.refresh_ttl default 20160 minutes; host of RemoteAddr as /register records it).
+
+controllers/registration.go: `RegisterOptions` gains `PasswordLess bool` with the doc "PasswordLess creates the account with has_self_set_password=false: its password is random and was never known to the user (social sign-in, PHP password_bootstrap_source oauth_registration). The zero value keeps true." In `RegisterUser` set `user.HasSelfSetPassword = !opts.PasswordLess`. New `FinishLogin(ctx context.Context, app *backpack.App, db *gorm.DB, user *models.User, ip string) error` with the doc "FinishLogin runs the steps password Login takes after it has authenticated a user, for a caller that authenticated the user another way (a social sign-in). It restores a soft-deleted account and sends the reactivate mail, records ip as last_ip_address, and touches last_seen. It checks no password, throttle, activation or ban and fires no event; the caller does those checks (Go has no login event)." Body: when `user.DeletedAt.Valid`, `db.WithContext(ctx).Unscoped().Model(user).Update("deleted_at", nil)` (error returned) then the same reactivate mail call Login makes, and clear `user.DeletedAt`; when ip is non-empty update `last_ip_address` (error returned) and set it on the struct; `classes.TouchLastSeen` failure is logged as a warning like Login does and does not fail. Do not change Login's behaviour (TestLogin and the session tests must pass unchanged); Login may call a shared unexported restore helper only if that keeps its tests unchanged.
+
+README (same commit): the "OAuth providers" Features bullet now says the list comes from a social-login plugin's published `classes.OAuthProviderSource` and is empty without one; the API reference gains rows for `classes.OAuthProvider`, `classes.OAuthProviderSource`, `controllers.RegistrationSettings`, `controllers.RegistrationsFromIP`, `controllers.RefreshTTL`, `controllers.RemoteIP`, `controllers.FinishLogin`; the registration-helper paragraph (:163) documents `RegisterOptions.PasswordLess`. Neutral wording ("a social-login plugin"); no application named.
+
+Smoke tests. oauth_providers_smoke_test.go `TestOAuthProvidersSourceSmoke` (no database, runs under -short): build a `backpack.App` with a compass config, call `controllers.OAuthProviders(app)` through httptest: without a source the body bytes equal `{"success":true,"providers":[]}` (compare after trimming a trailing newline); after `app.Publish[classes.OAuthProviderSource](fakeSource{...})` with one enabled provider google/Google the body is `{"success":true,"providers":[{"label":"Google","name":"google"}]}` or the key order wire produces (assert by decoding and comparing the slice, plus Cache-Control `no-cache, private`). social_hooks_smoke_test.go `TestSocialHooksSmoke` (Postgres, skips under -short, uses sessionApp): `RegisterUser` with fields email, password, password_confirmation and `RegisterOptions{Activate: true, IP: "203.0.113.7", PasswordLess: true}` creates a user with has_self_set_password false, is_activated true and a non-empty handle; `FinishLogin` on a soft-deleted user clears deleted_at, sets last_ip_address to the given ip, sets last_seen, and the captured mailer holds exactly one reactivate message.
+
+Commit in sm-user-plugin: `feat: provider source for oauth-providers and sign-in helpers for social login`.
+
+ go -C ../fonoteka.go vet ./plugins/golem15/user/... && go -C ../fonoteka.go test -count=1 -short ./plugins/golem15/user/... && go -C ../fonoteka.go test -count=1 -short -run '^TestOAuthProvidersSourceSmoke$' -v ./plugins/golem15/user 2>&1 | grep -q -- '^--- PASS: TestOAuthProvidersSourceSmoke' && go -C ../fonoteka.go build ./... && go -C ../sm-oauthserver-plugin build ./...
+ Non-zero exit: vet or the short user-plugin suite is red, no "--- PASS: TestOAuthProvidersSourceSmoke" line, fonoteka.go no longer compiles against the plugin, or sm-oauthserver-plugin (which builds against this checkout through its go.work) fails to compile (a non-additive change).
+ go -C ../fonoteka.go test -count=1 -run '^(TestSocialHooksSmoke|TestLogin|TestOAuthProvidersSourceSmoke)$' -v ./plugins/golem15/user 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 3
+ The count of top-level "--- PASS: Test" lines is not 3: TestSocialHooksSmoke or TestLogin failed or skipped (a "--- SKIP" means Docker was not reachable: rerun through sudo -u golem, else record NOT RUN in the SUMMARY).
+
+
+ - `grep -c 'type OAuthProviderSource interface' ../fonoteka.go/plugins/golem15/user/classes/oauth_providers.go` prints at least 1.
+ - `grep -c 'Lookup\[classes.OAuthProviderSource\]' ../fonoteka.go/plugins/golem15/user/controllers/api_controller.go` prints at least 1.
+ - `grep -cE '^func (RegistrationSettings|RegistrationsFromIP|RefreshTTL|RemoteIP|FinishLogin)\(' ../fonoteka.go/plugins/golem15/user/controllers/*.go | awk -F: '{s+=$2} END {print s}'` prints at least 5.
+ - `grep -c 'PasswordLess' ../fonoteka.go/plugins/golem15/user/controllers/registration.go` prints at least 2 and `grep -c 'PasswordLess' ../fonoteka.go/plugins/golem15/user/README.md` prints at least 1.
+ - `D=$(git -C ../fonoteka.go/plugins/golem15/user diff --stat 4c5d3b8 -- updates) && test -z "$D"` exits 0 (no migration touched since the phase began, D-22).
+ - `grep -cE 'OAuthProviderSource|FinishLogin|RefreshTTL' ../fonoteka.go/plugins/golem15/user/README.md` prints at least 3.
+
+ A plugin that publishes an OAuthProviderSource fills the login-button list without changing the empty-list contract, and can create a password-less user and finish a login through exported user-plugin helpers.
+
+
+
+ Task 3: Unlinking follows the PHP rule under a row lock, Microsoft can be unlinked, user:backfill-handles opens its own database, and the folded todo is closed
+ D-13 changes one handler's SQL behind unchanged status codes and bodies; reverting is a one-file change.
+ ../fonoteka.go/plugins/golem15/user/controllers/oauth_identities.go, ../fonoteka.go/plugins/golem15/user/oauth_identities_test.go, ../fonoteka.go/plugins/golem15/user/console/backfill_handles.go, ../fonoteka.go/plugins/golem15/user/console_test.go, ../fonoteka.go/plugins/golem15/user/README.md, .planning/todos/pending/user-plugin-commands-open-database.md, .planning/todos/done/user-plugin-commands-open-database.md
+ ../fonoteka.go/plugins/golem15/user/controllers/oauth_identities.go, ../fonoteka.go/plugins/golem15/user/oauth_identities_test.go (:101-155 and helpers :264-290), ../fonoteka.go/plugins/golem15/user/console/backfill_handles.go, ../fonoteka.go/plugins/golem15/user/console_test.go (:63), ../fonoteka.go/plugins/golem15/user/plugin.go (:207-227), ../fonoteka.go/plugins/golem15/user/README.md (Host-mounted OAuth identities :108-116, CLI commands :209), 19.5-RESEARCH.md (Q4 unlink rule, Pitfall 1, Code Example 5), /media/nvme/dev/jz/wavepath.org/plugins/golem15/user/models/User.php (:913-932), .planning/todos/pending/user-plugin-commands-open-database.md, .planning/G15OFFICE.md
+ D-13 and D-24. In `OAuthIdentitiesDestroy`: the allow-list is `opts.Providers` when non-empty; otherwise, per request, the published `classes.OAuthProviderSource`'s `KnownOAuthProviders()` when one is published; otherwise `google`, `facebook`, `github`, `microsoft` (D-04 adds microsoft). Keep the auth deps, the allow-list 404 and the `First` row lookup with its 404 path unchanged. Replace the Count plus Delete with one `db.WithContext(r.Context()).Transaction(func(tx *gorm.DB) error {...})`: first `tx.Exec("SELECT id FROM users WHERE id = ? FOR UPDATE", user.ID)`, then the guarded DELETE ported from PHP User.php:913: `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)`; RowsAffected 0 answers 409 with exactly the current body `{"error": oauthLastMethodBlocked(r, app)}`, 1 answers 204, a database error answers writeOpaque500. A comment cites D-13 and RESEARCH Pitfall 1: under READ COMMITTED two guarded DELETEs on different rows do not wait for each other, so the user-row lock serialises them.
+
+Tests (oauth_identities_test.go). Rename `TestOAuthIdentitiesDestroyLastMethodBlockedWithPassword` to `TestOAuthIdentitiesDestroyLastIdentityWithPassword` and assert the PHP rule: a user with has_self_set_password true and one identity gets 204 and zero identities remain. `TestOAuthIdentitiesDestroyLastMethodBlockedENAndPL` must set has_self_set_password false on its user explicitly (the column defaults to TRUE) and keep asserting both 409 bodies. Add `TestOAuthIdentitiesDestroyConcurrentSmoke` (Postgres, deterministic, no sleeps as the only synchronisation): a password-less user (has_self_set_password false) with google and facebook identities; open transaction A on the raw gorm handle, run `SELECT id FROM users WHERE id = ? FOR UPDATE` and the guarded DELETE for google inside A and keep A open; start the handler for DELETE facebook in a goroutine; assert it has not answered within 300 ms (it waits on the row lock); commit A; the handler then answers 409 and exactly one identity (facebook) remains.
+
+Folded todo. console/backfill_handles.go keeps `BackfillHandles(app *backpack.App) bonfire.Command`. When `app.Lookup[*gorm.DB]()` finds none, open one with `lagoon.OpenFromApp(ctx, app)` for the command's duration and close the *sql.DB afterwards (the same rule as Plugin.withDB); other errors unchanged. console_test.go gains `TestBackfillHandlesOpensFromConfigWhenUnpublished` modelled on `TestConsoleWithDBOpensFromConfigWhenUnpublished`: no published DB, config pointing at the test database, a user without handle; the command succeeds and the user has a handle.
+
+README (in the commits that change the behaviour): the Host-mounted OAuth identities paragraph states the default providers (google, facebook, github, microsoft, or the published source's known names), and the PHP rule (unlinking succeeds when the user has a self-set password or another identity remains; it runs under a lock on the user row so concurrent unlinks never remove the last sign-in method; 409 body unchanged); CLI commands states that `user:backfill-handles` opens the database itself like the other commands. Commits in sm-user-plugin: `fix: atomic unlink with the PHP last-identity rule` and `fix: user:backfill-handles opens its own database`.
+
+Close the todo in summercms.go: `git mv .planning/todos/pending/user-plugin-commands-open-database.md .planning/todos/done/` and append one line: resolved by sm-user-plugin 24b16b1 (token and require-password-change commands) and 19.5-01 (user:backfill-handles), with the commit hash. Commit alone: `docs(19.5): close user-plugin console database todo`. Post a short comment on the Phase 19.5 milestone task in g15office (`onunbz3l`, .planning/G15OFFICE.md) naming plan 19.5-01's results; if the g15office tool is not available in this session, record that in the SUMMARY for the orchestrator.
+
+ go -C ../fonoteka.go vet ./plugins/golem15/user/... && go -C ../fonoteka.go test -count=1 -short ./plugins/golem15/user/... ./plugins/golem15/fonoteka/... && go -C ../fonoteka.go build ./... && go -C ../sm-oauthserver-plugin build ./...
+ Non-zero exit: vet or a short user-plugin or fonoteka-plugin test is red, or fonoteka.go or sm-oauthserver-plugin no longer compiles.
+ go -C ../fonoteka.go test -count=1 -run '^(TestOAuthIdentitiesDestroyLastIdentityWithPassword|TestOAuthIdentitiesDestroyLastMethodBlockedENAndPL|TestOAuthIdentitiesDestroyConcurrentSmoke|TestOAuthIdentitiesDestroyNoContentKeepsSibling|TestBackfillHandlesOpensFromConfigWhenUnpublished)$' -v ./plugins/golem15/user 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 5
+ The count of top-level "--- PASS: Test" lines is not 5: one of the Postgres-backed unlink or console tests failed, was not found, or skipped (a "--- SKIP" means no Docker: rerun through sudo -u golem, else record NOT RUN).
+
+
+ - `grep -c 'FOR UPDATE' ../fonoteka.go/plugins/golem15/user/controllers/oauth_identities.go` prints at least 1 and `grep -c 'has_self_set_password' ../fonoteka.go/plugins/golem15/user/controllers/oauth_identities.go` prints at least 1.
+ - `grep -c '"microsoft"' ../fonoteka.go/plugins/golem15/user/controllers/oauth_identities.go` prints at least 1.
+ - `grep -c 'func TestOAuthIdentitiesDestroyLastMethodBlockedWithPassword' ../fonoteka.go/plugins/golem15/user/oauth_identities_test.go` prints 0 and `grep -c 'func TestOAuthIdentitiesDestroyLastIdentityWithPassword' ../fonoteka.go/plugins/golem15/user/oauth_identities_test.go` prints at least 1.
+ - `grep -c 'OpenFromApp' ../fonoteka.go/plugins/golem15/user/console/backfill_handles.go` prints at least 1 and `grep -c 'func BackfillHandles(app \*backpack.App) bonfire.Command' ../fonoteka.go/plugins/golem15/user/console/backfill_handles.go` prints at least 1.
+ - `test -f .planning/todos/done/user-plugin-commands-open-database.md && test ! -f .planning/todos/pending/user-plugin-commands-open-database.md` exits 0.
+ - `S=$(git -C ../fonoteka.go status --porcelain -- plugins/golem15/fonoteka parity) && test -z "$S"` exits 0 (no fonoteka.go file was edited by this plan).
+
+ A user with a password can unlink the last social identity, a password-less user never loses the last one even under concurrent requests, Microsoft identities can be unlinked, and every user console command works in the real binary.
+
+
+
+ Task 4: Package legitimacy check before installing github.com/markbates/goth v1.82.0 (runtime dependency of sm-sociallogin-plugin only)
+ .planning/notes/2026-10-20-sociallogin-goth.md, 19.5-RESEARCH.md (Q1, Package Legitimacy Audit, assumption A1)
+
+ - The SUMMARY records the human's reply verbatim ("approved" or "rejected") under a "Task 4 reply" heading.
+ - Until the reply, `test ! -e ../sm-sociallogin-plugin/go.mod || ! grep -q 'markbates/goth' ../sm-sociallogin-plugin/go.mod` exits 0 and `grep -c 'markbates/goth' go.mod` prints 0.
+
+ Nothing installed. Tasks 1-3 are committed: the decision note naming goth v1.82.0, cooler.Cache.Pull, and the sm-user-plugin hooks. RESEARCH verified goth on proxy.golang.org (v1.82.0 published 2025-08-18T03:17:23Z, origin https://github.com/markbates/goth at tag v1.82.0, hash 6c7fc319f1f5…, MIT, 6.6k stars), measured its import graph and binary delta, and ran govulncheck (clean). The package-legitimacy seam cannot check Go modules, so goth carries [ASSUMED] and needs your check (19.4 precedent). It will be required only by sm-sociallogin-plugin and appear in the module graph (go.sum) of the applications that mount it. The repository advertises a maintainer search; the decision note explains how the plugin's own Provider interface contains that risk. Never auto-approved.
+
+ 1. Open https://pkg.go.dev/github.com/markbates/goth and https://github.com/markbates/goth: confirm the repository, the owner, the MIT licence and that v1.82.0 is a tagged release.
+ 2. In a scratch directory run `go list -m -json github.com/markbates/goth@v1.82.0` and confirm Origin.URL is https://github.com/markbates/goth and Origin.Ref is refs/tags/v1.82.0.
+ 3. Skim providers/google, providers/facebook, providers/github and providers/azureadv2 on GitHub (each under 300 lines) if you want to see what will run.
+ 4. Confirm goth is acceptable as a runtime dependency of sm-sociallogin-plugin (never gothic, never in the framework go.mod).
+
+ Reply "approved" to let plan 02 run `go get github.com/markbates/goth@v1.82.0` in sm-sociallogin-plugin, or "rejected" to stop: plan 02 must then not start, because D-02 locks goth, and the phase returns to /msd-discuss-phase 19.5 to choose another OAuth client.
+
+
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+|----------|-------------|
+| plugin code to cooler (memory or Redis) | one-time codes written by one request and consumed by another, possibly on another goroutine or instance |
+| authenticated caller to unlink handler | a JWT holder asks to remove a sign-in method of their own account |
+| social-login plugin to sm-user-plugin exports | another plugin creates users and finishes logins through the exported helpers |
+| operator to console commands | one-shot commands run in the real binary without a published database |
+
+## STRIDE Threat Register
+
+| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
+|-----------|----------|-----------|----------|-------------|-----------------|
+| T-19.5-01 | Spoofing | cooler.Cache.Pull (memory.go, rediscache.go) | high | mitigate | Redis GETDEL and otter Compute with InvalidateOp make get-and-delete one atomic step, so a one-time code is consumed at most once; expired entries report not found; TestPullSmoke now, the N-goroutine single-winner test in plan 04 |
+| T-19.5-02 | Denial of service | OAuthIdentitiesDestroy | high | mitigate | SELECT ... FOR UPDATE on the users row before the guarded DELETE serialises concurrent unlinks, so a password-less user never ends with zero sign-in methods (the PHP shape races on Postgres, RESEARCH Pitfall 1); TestOAuthIdentitiesDestroyConcurrentSmoke |
+| T-19.5-03 | Elevation of privilege | OAuthIdentitiesDestroy rule relaxation (D-13) | medium | mitigate | the DELETE is scoped to the caller's user_id from the verified JWT and the path provider; it succeeds only with a self-set password or another remaining identity; 409 and 404 bodies unchanged; TestOAuthIdentitiesDestroyLastIdentityWithPassword and ...ENAndPL |
+| T-19.5-04 | Tampering | sm-user-plugin core contract | medium | mitigate | only added exports and fields; no route, payload, table, migration or config key changes; fonoteka.go and sm-oauthserver-plugin compile and pass in the verify commands; acceptance criterion proves no migration file changed |
+| T-19.5-05 | Spoofing | controllers.FinishLogin restoring a soft-deleted account | medium | mitigate | FinishLogin authenticates nobody: its doc requires the caller to have authenticated the user, plan 02 calls it only after an identity match or a verified email match, and the reactivate mail tells the owner, as password Login does |
+| T-19.5-06 | Information disclosure | logs of the new code | medium | mitigate | no code path logs a token, secret or code value; FinishLogin logs only user_id on a last_seen failure, as Login does |
+| T-19.5-SC | Tampering | Go module installs | high | mitigate | no module is installed in this plan; goth v1.82.0 waits for the Task 4 blocking human check (never auto-approved); acceptance criteria grep every go.mod for markbates/goth |
+
+
+
+- Task 1: the note precedes the Pull code; the cooler suite and TestPullSmoke; TestDocsTree and docs:build --check.
+- Task 2: the short user-plugin suite, TestOAuthProvidersSourceSmoke, TestSocialHooksSmoke and TestLogin on Postgres; fonoteka.go and sm-oauthserver-plugin compile.
+- Task 3: five Postgres-backed unlink and console tests by name; fonoteka plugin suite; the todo moved.
+- Task 4: the human reply recorded; no go.mod requires goth before it.
+- Feedback sampling (19.5-VALIDATION.md): after each commit run the quick command of the touched repository.
+
+
+
+- D-02: decision note on record before any code; goth gated by a human check.
+- D-08: cooler.Cache.Pull on both drivers with README and docs.
+- D-13, D-24: PHP unlink rule, race-safe, fonoteka unaffected except the relaxation.
+- D-15: oauth-providers reads a published source and keeps the empty contract without one.
+- Folded todo closed.
+
+
+
diff --git a/.planning/phases/19.5-social-login-in-sm-user-plugin/19.5-02-PLAN.md b/.planning/phases/19.5-social-login-in-sm-user-plugin/19.5-02-PLAN.md
new file mode 100644
index 00000000..1fe52447
--- /dev/null
+++ b/.planning/phases/19.5-social-login-in-sm-user-plugin/19.5-02-PLAN.md
@@ -0,0 +1,466 @@
+---
+phase: 19.5-social-login-in-sm-user-plugin
+plan: 02
+type: execute
+wave: 2
+depends_on: ["19.5-01"]
+files_modified:
+ - ../sm-sociallogin-plugin/.gitignore
+ - ../sm-sociallogin-plugin/go.mod
+ - ../sm-sociallogin-plugin/go.sum
+ - ../sm-sociallogin-plugin/go.work
+ - ../sm-sociallogin-plugin/plugin.go
+ - ../sm-sociallogin-plugin/routes.go
+ - ../sm-sociallogin-plugin/config.go
+ - ../sm-sociallogin-plugin/service.go
+ - ../sm-sociallogin-plugin/admin_settings.go
+ - ../sm-sociallogin-plugin/admin_permissions.go
+ - ../sm-sociallogin-plugin/config/config.yaml
+ - ../sm-sociallogin-plugin/lang/en/lang.yaml
+ - ../sm-sociallogin-plugin/lang/pl/lang.yaml
+ - ../sm-sociallogin-plugin/classes/provider.go
+ - ../sm-sociallogin-plugin/classes/credentials.go
+ - ../sm-sociallogin-plugin/classes/statecookie.go
+ - ../sm-sociallogin-plugin/classes/callback.go
+ - ../sm-sociallogin-plugin/classes/codes.go
+ - ../sm-sociallogin-plugin/classes/flow.go
+ - ../sm-sociallogin-plugin/classes/registration.go
+ - ../sm-sociallogin-plugin/classes/messages.go
+ - ../sm-sociallogin-plugin/classes/providers_goth.go
+ - ../sm-sociallogin-plugin/controllers/redirect.go
+ - ../sm-sociallogin-plugin/controllers/callback.go
+ - ../sm-sociallogin-plugin/controllers/complete.go
+ - ../sm-sociallogin-plugin/controllers/register_complete.go
+ - ../sm-sociallogin-plugin/controllers/link_ticket.go
+ - ../sm-sociallogin-plugin/models/registry.go
+ - ../sm-sociallogin-plugin/models/settings.go
+ - ../sm-sociallogin-plugin/models/settings/fields.yaml
+ - ../sm-sociallogin-plugin/updates/registry.go
+ - ../sm-sociallogin-plugin/updates/202610200001_create_golem15_sociallogin_settings.go
+ - ../sm-sociallogin-plugin/testmain_test.go
+ - ../sm-sociallogin-plugin/harness_test.go
+ - ../sm-sociallogin-plugin/fakeprovider_test.go
+ - ../sm-sociallogin-plugin/tracer_smoke_test.go
+ - ../sm-sociallogin-plugin/flow_smoke_test.go
+ - ../sm-sociallogin-plugin/link_smoke_test.go
+ - ../sm-sociallogin-plugin/providers_smoke_test.go
+ - ../sm-sociallogin-plugin/settings_smoke_test.go
+ - ../sm-sociallogin-plugin/README.md
+autonomous: false
+requirements: [SC-1, SC-2, SC-3, D-01, D-02, D-03, D-04, D-05, D-06, D-07, D-08, D-09, D-10, D-11, D-12, D-14, D-16, D-17, D-18, D-19, D-20, D-21, D-22, D-23]
+user_setup:
+ - service: git.golem15.com
+ why: "Create the golem15/sm-sociallogin-plugin repository and push the framework, sm-user-plugin and the new plugin (D-21), only after the Task 5 go-ahead"
+ dashboard_config:
+ - task: "Approve the release train in Task 5; the executor runs tea repos create and git push under that approval"
+ location: "git.golem15.com, organisation golem15"
+estimate:
+ tokens: 330000
+ raw_tokens: 330000
+ tasks: 6
+ confidence: low
+must_haves:
+ truths:
+ - "D-01: a new repository sm-sociallogin-plugin (module git.golem15.com/golem15/sm-sociallogin-plugin, package sociallogin, plugin ID golem15.sociallogin) requires golem15.user; an application without it has no goth dependency, no /oauth/{provider} routes and no sociallogin config."
+ - "D-03: the plugin exports its own Provider interface, Profile and ProviderConfig types and a Registry (Register, Names, Label, Build); no exported identifier mentions a goth type; tests register fake providers through the same Registry and drive redirect, callback, completion, pending and link end to end with no network."
+ - "D-04, D-20: GET /oauth/{provider} and GET /oauth/{provider}/callback sit behind throttle:20,1 and accept only registered names; an unknown or reserved name (authorize, authorizations, jwks, token, userinfo, mcp, register, well-known, callback) answers 404; a registered provider without credentials answers 302 to the callback with ?error=; google, facebook, github and microsoft are registered built-ins."
+ - "D-05, D-06, D-17: mode is accepted and ignored (SPA only); frontend_callback is accepted only when its path equals callback_path and, if absolute, its scheme is http or https and its host exactly matches localhost, 127.0.0.1, the request host, the app.url host, the frontend_url host or an allowed_hosts entry; the redirect target is rebuilt from scheme, host and path; an invalid or missing callback falls back to default_callback; return_to must start with / and not // (else /)."
+ - "D-07: the state, action, callback, return_to, consent with timestamp, beta key, link user and goth's marshalled session live in one cookie summer_oauth_ sealed with AES-256-GCM under an HKDF key from the app key (label golem15.sociallogin.oauth-state.v1, AAD the cookie name), HttpOnly, SameSite=Lax, Path=/oauth, Max-Age 600, Secure from the bouncer cookie config; the callback clears it on every outcome and rejects a missing, tampered, older-than-600-s, other-provider, empty or unequal state (constant-time) with the invalid_state message."
+ - "D-08, D-09: oauth-complete:{code} (64 chars, 5 min) carries {token, user, action, return_to} and GET /_user/api/v1/oauth-complete answers 200 with it, 422 on an empty code and 410 on a missing or used code (cooler.Pull); with golem15.sociallogin.token_cookie true the token goes into bouncer's token cookie and not into the body; oauth-pending-registration:{code} (10 min) carries the PHP fields plus beta_key and is pulled only by a successful POST /_user/api/v1/oauth-register-complete, so a 422 keeps it."
+ - "D-10, D-11, D-19, D-22: the callback ports PHP's branches: identity login; no email pending fb_no_email; email match (case-insensitive) auto-links only when the provider wrapper asserts the email verified, else pending unverified_match; no match pending for login and direct creation for register (allow_registration, register throttle, terms and privacy consent at most 600 s old, parental_authority when sent, beta key); every login requires an activated, unbanned user; new users are activated, password-less (has_self_set_password false), get a handle from the provider name or email local part, marketing_consent when opted in, and the consent details travel in RegisterEvent.Payload; provider tokens are stored encrypted with token_expires_at and the avatar only in profile_data.avatar."
+ - "D-11: Google is verified only through a boolean verified_email or email_verified true; GitHub's email is the primary verified address from /user/emails and therefore verified; Facebook and Microsoft are never verified, so a Microsoft login whose mail equals an existing account's email goes to pending unverified_match."
+ - "D-12: with golem15.sociallogin.beta_key set, account creation on both paths (callback register and oauth-register-complete) requires a matching key compared in constant time (carried from the start request or sent with the completion); empty key means no gate."
+ - "D-14, D-23: action=link needs a user from a pulled one-time link_ticket or userclasses.RequestUser (bearer, then token or auth_token cookie); POST /_user/api/v1/oauth-link-ticket returns a 60-second ticket to an authenticated caller; an identity owned by another user is refused with account_already_linked; no JWT ever appears in a URL."
+ - "D-15: the plugin publishes userclasses.OAuthProviderSource with an explicit type argument, so /_user/api/v1/oauth-providers lists the enabled providers {name,label} in registry order."
+ - "D-16, D-18: credentials come from golem15.sociallogin.providers.
.client_id|client_secret (+tenant for microsoft, +redirect) with legacy env names copied at Boot, overridden per field by the golem15_sociallogin_settings singleton read per request; secrets are lagoon.Encrypted, write-only in the admin form and never logged; a provider is enabled when id and secret are both non-empty; the redirect URI is the per-provider redirect override, else app.url + /oauth/
/callback, else the request origin + /oauth/
/callback when app.url is empty."
+ - "D-02, D-21: goth v1.82.0 is installed only after plan 01's recorded approval, only classes/providers_goth.go imports it, gothic is never imported and go list -deps shows no gorilla/sessions; after the Task 5 go-ahead the remote operations run in D-21 order: framework, sm-user-plugin, then the new plugin repository."
+ artifacts:
+ - path: "../sm-sociallogin-plugin/classes/provider.go"
+ provides: "Provider, Profile, ProviderConfig, Factory, Registry"
+ contains: "type Provider interface"
+ - path: "../sm-sociallogin-plugin/classes/statecookie.go"
+ provides: "sealed state cookie"
+ contains: "golem15.sociallogin.oauth-state.v1"
+ - path: "../sm-sociallogin-plugin/classes/flow.go"
+ provides: "D-10 matching, linking and login"
+ contains: "unverified_match"
+ - path: "../sm-sociallogin-plugin/classes/providers_goth.go"
+ provides: "google, facebook, github, microsoft wrappers (only goth importer)"
+ contains: "azureadv2"
+ - path: "../sm-sociallogin-plugin/tracer_smoke_test.go"
+ provides: "TestTracerSmoke: fake provider redirect, callback, complete, providers list"
+ contains: "TestTracerSmoke"
+ key_links:
+ - from: "../sm-sociallogin-plugin/service.go"
+ to: "../fonoteka.go/plugins/golem15/user/classes/oauth_providers.go"
+ via: "typed Publish of userclasses.OAuthProviderSource"
+ pattern: "Publish\\[userclasses.OAuthProviderSource\\]"
+ - from: "../sm-sociallogin-plugin/controllers/complete.go"
+ to: "modules/cooler/cooler.go"
+ via: "completion codes are consumed with cooler.Cache.Pull"
+ pattern: "Pull\\("
+ - from: "../sm-sociallogin-plugin/classes/flow.go"
+ to: "../fonoteka.go/plugins/golem15/user/controllers/registration.go"
+ via: "RegisterUser with PasswordLess, FireRegisterEvent, FireCreatedEvent, FinishLogin, IssueToken, APIArray"
+ pattern: "PasswordLess"
+ - from: "../sm-sociallogin-plugin/routes.go"
+ to: "surf throttles"
+ via: "throttle:20,1 on /oauth and throttle:user-api on /_user/api/v1"
+ pattern: "throttle:20,1"
+ prohibitions:
+ - statement: "No JWT in a URL: only 64-character one-time codes and link tickets travel in query strings; there is no ?token= parameter anywhere."
+ status: unresolved
+ verification: judgment
+ - statement: "github.com/markbates/goth/gothic and goth.UseProviders are never imported or called, and github.com/gorilla/sessions is not in the plugin's package dependency graph."
+ status: unresolved
+ verification: judgment
+ - statement: "Client secrets, provider access and refresh tokens, JWTs, one-time codes, link tickets and oauth2.RetrieveError bodies are never logged or echoed to the admin form."
+ status: unresolved
+ verification: judgment
+ - statement: "No web-mode fallback (Winter session login, /select, /parent/settings) and no admin screen listing a user's social identities (CONTEXT Deferred Ideas)."
+ status: unresolved
+ verification: judgment
+ - statement: "No migration, column or default-group assignment is added to sm-user-plugin (D-22); sociallogin owns only golem15_sociallogin_settings."
+ status: unresolved
+ verification: judgment
+ - statement: "YAML is parsed with goccy/go-yaml only; gopkg.in/yaml.v3 is never imported."
+ status: unresolved
+ verification: judgment
+ - statement: "Nothing is pushed, tagged or created remotely before the Task 5 reply, and never force-pushed."
+ status: unresolved
+ verification: judgment
+---
+
+## Phase Goal
+
+ROADMAP Goal (not in user-story format; no story invented, see plan 01): `sm-user-plugin` signs users in with Google and Facebook through `/oauth/{provider}` and its callback, with a provider registry open to more providers, the SPA one-time-code handoff and the pending-registration branch, matching the PHP user plugin's contract. CONTEXT D-01 puts this in the new shared plugin `sm-sociallogin-plugin`.
+
+This plan's slice (plan 2 of 4, wave 2, D-25 "sm-sociallogin-plugin"): the whole plugin. The tracer proves the returning-user path end to end with a fake provider registered through the plugin's own registry. Expansion slices add new users and unverified matches, linking with the link ticket and the request-side validation, then the real Google, Facebook, GitHub and Microsoft providers through goth with the admin settings override. The plan ends with the release go-ahead and, under it, the remote operations in D-21 order, because plan 03 can only mount a plugin whose repository exists.
+
+## Spec-less probe fallback
+
+spec-less probe fallback skipped: phase has no requirement IDs to probe (visible skip). `must_haves` derive from SC-1..SC-4 and D-01..D-25; prohibitions recalled from CONTEXT and RESEARCH are listed without descriptors.
+
+## Decisions taken in this plan (recorded assumptions and Claude's discretion)
+
+- Identifiers (CONTEXT Claude's Discretion): package `sociallogin`; `classes.Provider` with `Name() string`, `Label() string`, `Begin(state string) (authURL, session string, err error)`, `Complete(ctx context.Context, session string, callback url.Values) (Profile, error)`; `classes.Factory func(ProviderConfig) (Provider, error)`; `classes.Registry` built per request from factories (RESEARCH Pattern 1), so a settings change applies on the next request (D-16). goth is a thin wrapper inside classes/providers_goth.go; provider errors reach the controllers only as `classes.ErrCodeExpired` or a plain error.
+- Cookie name `summer_oauth_` (one per provider, so two tabs on different providers do not clobber each other); payload JSON with `v` 1 and `iat`; sealed size at most 3800 bytes (`classes.MaxStateCookieBytes`).
+- One-time cache values (completion, pending, link ticket) are JSON sealed with AES-256-GCM under the HKDF label `golem15.sociallogin.code.v1` with the cache key as AAD, so a Redis dump holds no JWT or provider token (RESEARCH Security Domain "optionally seal the payload"). The payload shapes stay D-08's.
+- Config keys (discretion): `golem15.sociallogin.frontend_url` (env `FRONTEND_URL`, default `app.url`), `golem15.sociallogin.callback_path` (default `/auth/oauth/callback`), `golem15.sociallogin.default_callback` (default frontend_url + callback_path), `golem15.sociallogin.allowed_hosts` (list), `golem15.sociallogin.token_cookie` (default false; D-09 opt-in, applied to oauth-complete and oauth-register-complete), `golem15.sociallogin.beta_key` (env `BETATESTER_KEY`), `golem15.sociallogin.http_timeout` (seconds, default 10), `golem15.sociallogin.consent.terms_version` and `.privacy_version` (default `2024-12-17`, PHP `gdpr.current_*_version`), `golem15.sociallogin.providers.
.client_id|client_secret|redirect` and `providers.microsoft.tenant` (default `common`). Legacy env names copied at Boot only onto empty keys (sm-herald-plugin copyEnv): `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `FACEBOOK_CLIENT_ID`, `FACEBOOK_CLIENT_SECRET`, `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET`, `MICROSOFT_CLIENT_ID`, `MICROSOFT_CLIENT_SECRET`, `MICROSOFT_TENANT`, `BETATESTER_KEY`, `FRONTEND_URL`. The Facebook Graph version is not pinned (goth cannot; RESEARCH Pitfall 6).
+- D-08 "validate before pulling": oauth-register-complete reads the pending payload with `Get`, runs every check that can answer 422 (consent, beta key, unverified email match) on it, and only then `Pull`s; a lost race on Pull answers 410. So only a successful completion consumes the code, and a concurrent double submit yields one 200 and one 410.
+- D-12 on the pending path (RESEARCH Pitfall 14): the start request's `beta_key` travels through the cookie into the pending payload; oauth-register-complete accepts that key or a `beta_key` field in its body, compared in constant time.
+- Error messages: PHP's lang texts (RESEARCH Q4 table) as `golem15.sociallogin::lang.oauth.*` en and pl; inactive and banned users get real messages instead of PHP's raw missing keys (assumption A7); `account_already_linked` gets the provider label for `:provider` (PHP left the placeholder literal); the oauth-register-complete consent 422 keeps PHP's Polish text as the pl value and gets a new English text (assumption A6).
+- Unknown or reserved provider → 404; disabled provider, invalid action, link without a user → 302 to the resolved callback with `?error=` (RESEARCH Open Question 7). Soft-deleted accounts are restored only on an identity match or a verified email match (Open Question 3). Microsoft email falls back to `userPrincipalName` when Graph `mail` is empty, still unverified (Open Question 5). Matching by email is `lower(email) = lower(?)` over all users including trashed ones (Pitfalls 10, 11).
+- Release (D-21, CONTEXT discretion "the framework tag is chosen at execution"): Task 5 asks once for every remote operation of the phase, including plan 03's pushes; the changelog post is written only for a framework tag (sm-summercmsio-app DEPLOY.md "Every framework release").
+- Context budget (estimate 330k, 6 tasks; size accepted with the four-plan decision and the project's lean-planning rule): the Task 5 checkpoint splits execution, so Tasks 1-4 run in the first context and Task 6 in a continuation agent that reads this plan, the 19.5-01 and 19.5-02 SUMMARY drafts and the repositories from disk. If the first context runs low after Task 3, the executor may hand Task 4 to a fresh agent the same way.
+
+## Artifacts this phase produces (this plan)
+
+- Repository `../sm-sociallogin-plugin` (branch master), module `git.golem15.com/golem15/sm-sociallogin-plugin`, package `sociallogin`; later remote `git@git.golem15.com:golem15/sm-sociallogin-plugin.git`, tag `v0.1.0` (Task 6).
+- Plugin: `sociallogin.PluginID` (`golem15.sociallogin`), `sociallogin.Plugin` (ID, Requires `golem15.user`, Register, Boot, ConfigFS, LangFS, Routes, Migrations, Models, Settings, AdminFS, Permissions), `sociallogin.Config` and `sociallogin.LoadConfig(app *backpack.App) (Config, error)` (fields `FrontendURL`, `CallbackPath`, `DefaultCallback`, `AllowedHosts`, `TokenCookie`, `BetaKey`, `HTTPTimeout`, `TermsVersion`, `PrivacyVersion`), constants `sociallogin.SettingsCode` (`golem15.sociallogin.settings`) and `sociallogin.PermissionAccessSettings` (`golem15.sociallogin.access_settings`).
+- classes: `Profile` (ID, Email, Name, AvatarURL, EmailVerified, AccessToken, RefreshToken, ExpiresAt, Raw), `ProviderConfig` (ClientID, ClientSecret, RedirectURL, Tenant, HTTPClient), `Provider`, `Factory`, `Registry`, `NewRegistry() *Registry`, `(*Registry).Register(name, label string, f Factory) error`, `(*Registry).Names() []string`, `(*Registry).Label(name string) (string, bool)`, `(*Registry).Build(name string, cfg ProviderConfig) (Provider, error)`, `RegistryFromApp(app *backpack.App) (*Registry, bool)`, `ReservedNames`, errors `ErrReservedName`, `ErrDuplicateProvider`, `ErrUnknownProvider`, `ErrCodeExpired`; `ResolveCredentials(ctx context.Context, app *backpack.App, name string) (ProviderConfig, bool, error)`; `State`, `Consent`, `SealState`, `OpenState`, `ClearStateCookie`, `StateCookieName(provider string) string`, `StateTTL`, `MaxStateCookieBytes`; `NormalizeFrontendCallback`, `NormalizeReturnTo`, `HostSet`, `ResolveCallback`; `NewCode() (string, error)`, `CompletionPayload`, `PendingPayload`, `PutCompletion`, `PullCompletion`, `PutPending`, `GetPending`, `PullPending`, `PutLinkTicket`, `PullLinkTicket`, TTL constants `CompletionTTL` (5 min), `PendingTTL` (10 min), `LinkTicketTTL` (60 s); `Outcome` and `HandleCallback` (flow.go), `CreateUser` (registration.go), `FlowError` and `Message` (messages.go); `RegisterBuiltins(reg *Registry) error` (providers_goth.go).
+- controllers: `Redirect`, `Callback`, `Complete`, `RegisterComplete`, `LinkTicket` (handler factories taking `*backpack.App`).
+- service.go: `providerSource` implementing `userclasses.OAuthProviderSource`.
+- models: `models.Settings` (table `golem15_sociallogin_settings`), `models.LoadSettings(ctx context.Context, db *gorm.DB) (*Settings, error)`, `(Settings).SecretFallbacks()`; migration `202610200001_create_golem15_sociallogin_settings`; admin form `models/settings/fields.yaml`.
+- Routes: `GET /oauth/{provider}`, `GET /oauth/{provider}/callback` (throttle:20,1); `GET /_user/api/v1/oauth-complete`, `POST /_user/api/v1/oauth-register-complete`, `POST /_user/api/v1/oauth-link-ticket` (throttle:user-api).
+- Lang keys `golem15.sociallogin::lang.oauth.*`, `golem15.sociallogin::lang.register.*`, `golem15.sociallogin::lang.settings.*`, `golem15.sociallogin::lang.permissions.*`.
+- Smoke tests: `TestTracerSmoke`, `TestFlowSmoke`, `TestLinkSmoke`, `TestGothProvidersSmoke`, `TestSettingsOverrideSmoke`; test doubles `fakeProvider` (fakeprovider_test.go), harness `bootSocial`, `freshDB`.
+
+
+Build sm-sociallogin-plugin: a fake-provider tracer through redirect, callback and completion, then the PHP matching and registration branches, linking with the link ticket and request validation, the four real providers through goth with the settings override, and finally the release go-ahead and the remote operations in D-21 order.
+
+Purpose: D-01..D-12, D-14..D-23; SC-1 (PHP paths), SC-2 (code handoff and pending registration), SC-3 (identities linked on login).
+Output: commits in the new repository (local until Task 6), and after the go-ahead the framework tag and changelog post, the sm-user-plugin push and tag, the new remote repository with the plugin pinned to the released versions.
+
+
+
+@~/.claude/msd-core/workflows/execute-plan.md
+@~/.claude/msd-core/templates/summary.md
+
+
+
+@.planning/phases/19.5-social-login-in-sm-user-plugin/19.5-CONTEXT.md
+@.planning/phases/19.5-social-login-in-sm-user-plugin/19.5-RESEARCH.md
+@.planning/phases/19.5-social-login-in-sm-user-plugin/19.5-PATTERNS.md
+@.planning/phases/19.5-social-login-in-sm-user-plugin/19.5-01-SUMMARY.md
+@.planning/notes/2026-10-20-sociallogin-goth.md
+
+Repos and concurrency: this plan creates and writes ../sm-sociallogin-plugin (a sibling of summercms.go, like ../sm-oauthserver-plugin). It reads ../fonoteka.go/plugins/golem15/user (the sm-user-plugin checkout that go.work replaces) and summercms.go; it changes neither until Task 6, which only pushes and tags them. If a defect in sm-user-plugin blocks a task, fix it there in its own commit with its README change and record it as a deviation. No co-author tags; stage by explicit path; one logical change per commit. Keep `go vet ./...` and `go test -short ./...` green in the plugin at every commit. Docker-backed tests skip under -short; run them on a Docker host (through sudo -u golem if the socket is denied) and record NOT RUN when no daemon answers.
+
+PHP source of truth (read-only): /media/nvme/dev/jz/wavepath.org/plugins/golem15/user/components/SocialAuth.php (redirect :111, state :213, matching :284, pending :919, completePendingRegistration :995, return_to :1185, frontend_callback :1202, verified rule :897, link user :779, linking :638-706), controllers/ApiController.php (:1066-1170), routes.php (:165-183, :238-252), lang/en/lang.php and lang/pl/lang.php, models/User.php (linkOAuthProvider :822, recordConsent :1073). RESEARCH Q4 quotes every message, parameter order and status; treat it as the spec and the PHP files as the tie-breaker. Horoskopia's client (/media/nvme/dev/golem15/horoskopia.eu/vue-horoskopia-app/app/stores/auth.ts:432-551) defines the query contract.
+
+
+- sm-oauthserver-plugin (analog, read-only): plugin.go (interface assertions, PluginID, embeds, Register/Boot, init party.Register), routes.go:29-80 (GroupRaw with redirects), servers.go:60-75 (LoadConfig), config/config.yaml:1-4 (header naming SUMMER_GOLEM15____ and the PHP env name), admin_permissions.go (pact.Permission catalog), controllers/authorizations.go:35-161 (handler factories, appDB helper, writeJSON), testmain_test.go (flag.Parse, testing.Short, postgres:16-alpine, freshDB), harness_test.go:79-230 (tryBootOAuth: compass env SUMMER_APP__KEY, SUMMER_GOLEM15__USER__JWT__SECRET, lagoon.LoadAppKey, PublishEncryptionKeys, Publish, party.Activate, lagoon.Migrate; helpers user, sessionToken, browser, do), go.mod (replace summercms => ../summercms.go at :150), go.work.
+- sm-user-plugin exports (plan 01 SUMMARY has the final names): `userclasses.OAuthProviderSource`, `userclasses.OAuthProvider`, `userclasses.RequestUser(ctx, app, r) (*models.User, error)` (bearer, then cookies token and auth_token; blacklist and tokens_valid_after checked), `userclasses.LookupByEmail`, `usermodels.User`, `usermodels.OAuthIdentity` (table golem15_user_oauth_identities, unique (user_id, provider) and (provider, provider_id), AccessToken/RefreshToken lagoon.Encrypted, TokenExpiresAt, ProfileData lagoon.Jsonable[map[string]any], LinkedAt), `usermodels.Throttle` (IsBanned), `userctrl.RegisterUser`, `userctrl.RegisterOptions{Activate, IP, PasswordLess}`, `userctrl.FireRegisterEvent`, `userctrl.FireCreatedEvent`, `userctrl.FinishLogin`, `userctrl.IssueToken`, `userctrl.APIArray`, `userctrl.RegistrationSettings`, `userctrl.RegistrationsFromIP`, `userctrl.RefreshTTL`, `userctrl.RemoteIP`; the user plugin's 401 body `{"error": true, "message": "Unauthorized"}` (writeUnauthorized) and its localized-message helper pattern (`oauthLastMethodBlocked` in controllers/oauth_identities.go).
+- Framework: `cooler.FromApp(app)` and `cooler.Cache.Pull` (plan 01), `lagoon.LoadAppKey(cfg) ([]byte, [][]byte, error)`, `lagoon.Encrypted`, `lagoon.OnDatabase`, `bouncer.LoadCookieConfig(src)` and `(CookieConfig).TokenCookie(token, refreshTTL)`, `wire.WriteJSON`, `wire.Slice`, `surf.Use`, `cabana.SecretFallbacks`, `pact.SettingsItem`, `pact.HasSettings`, `pact.Permission`, `backpack.App.Publish`/`Lookup`.
+- Settings analog: ../sm-pgstripe-plugin/models/settings.go (singleton row 1, LoadSettings, SecretFallbacks, Register in init), models/settings/fields.yaml (`type: password`), admin.go:20-42 (AdminFS, Settings()), updates/202610130001_create_golem15_pgstripe_settings.go and updates/registry.go.
+- Legacy env analog: ../sm-herald-plugin/boot.go:47-64 (copyEnv onto empty keys).
+- RESEARCH Pattern 1 (registry of factories), Pattern 2 (sealed cookie), Pattern 3 (provider list), Code Examples 1, 2, 4, Q3 (driving goth without gothic), Q4 (PHP contract tables), Pitfalls 3-11, 14, 15, Security Domain.
+
+
+
+
+
+
+ Task 1: Tracer: a returning user signs in through a fake provider registered in the plugin's registry: /oauth/{p} seals the state and redirects, the callback matches the existing identity, oauth-complete hands over {token,user,action,return_to} once, and oauth-providers lists the provider
+ D-01 and D-03: the module path, plugin ID, /oauth routes, config keys and the exported Provider/Registry shapes become what applications and other plugins reference once the plugin ships.
+ ../sm-sociallogin-plugin/.gitignore, ../sm-sociallogin-plugin/go.mod, ../sm-sociallogin-plugin/go.sum, ../sm-sociallogin-plugin/go.work, ../sm-sociallogin-plugin/plugin.go, ../sm-sociallogin-plugin/routes.go, ../sm-sociallogin-plugin/config.go, ../sm-sociallogin-plugin/service.go, ../sm-sociallogin-plugin/config/config.yaml, ../sm-sociallogin-plugin/lang/en/lang.yaml, ../sm-sociallogin-plugin/lang/pl/lang.yaml, ../sm-sociallogin-plugin/classes/provider.go, ../sm-sociallogin-plugin/classes/credentials.go, ../sm-sociallogin-plugin/classes/statecookie.go, ../sm-sociallogin-plugin/classes/callback.go, ../sm-sociallogin-plugin/classes/codes.go, ../sm-sociallogin-plugin/classes/flow.go, ../sm-sociallogin-plugin/classes/messages.go, ../sm-sociallogin-plugin/controllers/redirect.go, ../sm-sociallogin-plugin/controllers/callback.go, ../sm-sociallogin-plugin/controllers/complete.go, ../sm-sociallogin-plugin/testmain_test.go, ../sm-sociallogin-plugin/harness_test.go, ../sm-sociallogin-plugin/fakeprovider_test.go, ../sm-sociallogin-plugin/tracer_smoke_test.go, ../sm-sociallogin-plugin/README.md
+ 19.5-01-SUMMARY.md (final export names), ../sm-oauthserver-plugin/plugin.go, ../sm-oauthserver-plugin/routes.go, ../sm-oauthserver-plugin/servers.go (:60-75), ../sm-oauthserver-plugin/config/config.yaml, ../sm-oauthserver-plugin/go.mod, ../sm-oauthserver-plugin/go.work, ../sm-oauthserver-plugin/testmain_test.go, ../sm-oauthserver-plugin/harness_test.go, ../sm-oauthserver-plugin/README.md (structure), ../fonoteka.go/plugins/golem15/user/models/oauth_identity.go, ../fonoteka.go/plugins/golem15/user/controllers/registration.go, ../fonoteka.go/plugins/golem15/user/classes/session_user.go, modules/lagoon/encrypted.go (:45-49 HKDF, :179 LoadAppKey), modules/bouncer/cookie.go (:63, :145-159), modules/cooler/cooler.go, 19.5-RESEARCH.md (Pattern 1, Pattern 2, Q3, Q4 Callback and API tables, Code Examples 2 and 4, Pitfalls 7, 8, 9)
+ Repository (D-01): `git init -b master ../sm-sociallogin-plugin`; no remote (Task 6). go.mod: `module git.golem15.com/golem15/sm-sociallogin-plugin`, `go 1.27.0`, require `git.golem15.com/golem15/summercms v0.2.4` and `git.golem15.com/golem15/sm-user-plugin v0.1.1` for now plus `replace git.golem15.com/golem15/summercms => ../summercms.go` (sm-oauthserver-plugin go.mod precedent; Task 6 moves both requires to the released versions). go.work: `use .`, replace summercms => ../summercms.go, replace sm-user-plugin => ../fonoteka.go/plugins/golem15/user. No goth in this task. .gitignore for test binaries and coverage files.
+
+classes/provider.go (D-03; no goth import, no goth type in any exported signature): `Profile`, `ProviderConfig`, `Provider`, `Factory` and `Registry` exactly as listed in Artifacts. `Registry.Register` rejects names not matching `^[a-z][a-z0-9-]{1,31}$`, names in `ReservedNames` (authorize, authorizations, jwks, token, userinfo, mcp, register, well-known, callback; RESEARCH Pitfall 9: oauthserver's literal /oauth routes must stay unshadowed) with `ErrReservedName`, and duplicates with `ErrDuplicateProvider`; `Names` keeps registration order; `Build` calls the factory with the given config (fresh provider per request, RESEARCH Pattern 1) or returns `ErrUnknownProvider`. `ErrCodeExpired` is the sentinel a provider wraps when the provider refuses the code (invalid_grant or bad_verification_code). `RegistryFromApp(app)` looks up the published `*Registry`.
+
+classes/credentials.go: `ResolveCredentials(ctx, app, name)` reads `golem15.sociallogin.providers..client_id`, `.client_secret`, `.redirect`, `.tenant`; RedirectURL is the `.redirect` override, else strings.TrimSuffix(app.url, "/") + "/oauth/" + name + "/callback" (D-18), else (app.url empty) the path alone, which the Redirect and Callback handlers make absolute with the request's scheme (X-Forwarded-Proto aware, as the user plugin's requestURL) and host, the way Laravel's url() does; the provider still checks it against its registered redirect URIs; HTTPClient is an `*http.Client` with Timeout from `golem15.sociallogin.http_timeout` (default 10 s); enabled is true only when id and secret are both non-empty after trimming (D-16). Task 4 overlays the settings row here without changing the signature.
+
+classes/statecookie.go (D-07, RESEARCH Pattern 2 and Code Example 2): `State` (V, State, Provider, Action, FrontendCallback, ReturnTo, Consent *Consent, BetaKey, LinkUserID uint, Session, IssuedAt int64) and `Consent` (Terms, Privacy, Marketing bool, Parental *bool, At int64). AEAD: `lagoon.LoadAppKey(app.Config)` primary key, `hkdf.Key(sha256.New, primary, nil, "golem15.sociallogin.oauth-state.v1", 32)`, AES-256-GCM, fresh 12-byte nonce from crypto/rand, AAD the cookie name, base64.RawURLEncoding. `SealState(app, s) (*http.Cookie, error)` refuses a sealed value above MaxStateCookieBytes (3800); cookie Name `StateCookieName(provider)` = `summer_oauth_` + provider, Path `/oauth`, MaxAge 600, HttpOnly, SameSite Lax, Secure from `bouncer.LoadCookieConfig(app.Config)` (its https inference from app.url; on a config error use whether app.url starts with https). `OpenState(app, r, provider) (State, error)` returns one error value for missing, undecodable, unauthenticated, wrong version, IssuedAt older than StateTTL (600 s) or in the future by more than 60 s, and a provider that differs from the path. `ClearStateCookie(provider, secure)` returns the same name and path with MaxAge -1.
+
+classes/callback.go (D-06, D-17, RESEARCH Code Example 4): `NormalizeFrontendCallback(raw string, allowed HostSet, path string) (string, bool)` and `NormalizeReturnTo(s string) string` exactly as Code Example 4 (relative value returns the configured path; absolute needs http or https and an exact lowercase hostname in the set; the result is rebuilt as scheme://host[:port] + path, dropping userinfo, query and fragment; return_to rejects empty, not-/-prefixed, //, /\, backslash or control characters and more than 1024 bytes, giving /). `HostSet` holds localhost, 127.0.0.1, the request's hostname, the app.url host, the frontend_url host and every allowed_hosts entry. `ResolveCallback(cfg, r, raw)` returns the normalised callback or cfg.DefaultCallback (D-05).
+
+classes/codes.go (D-08): `NewCode()` = 48 bytes from crypto/rand as base64.RawURLEncoding (64 chars). Values are JSON sealed under HKDF label `golem15.sociallogin.code.v1` with the full cache key as AAD. Keys `oauth-complete:` + code (CompletionTTL 5 min) and, for later tasks, `oauth-pending-registration:` + code (PendingTTL 10 min) and `oauth-link-ticket:` + code (LinkTicketTTL 60 s). `CompletionPayload` fields Token, User (map[string]any), Action, ReturnTo with JSON names token, user, action, return_to. `PutCompletion(ctx, app, p) (string, error)` and `PullCompletion(ctx, app, code) (CompletionPayload, bool, error)` use `cooler.FromApp(app)` per call and `Pull` for consumption.
+
+classes/messages.go: `FlowError{Key string; Params map[string]string}` and `Message(ctx, app, r, key, params) string` resolving `golem15.sociallogin::lang.oauth.` through the app translator and request locale the way the user plugin's oauthLastMethodBlocked does. lang/en and lang/pl lang.yaml carry every message of RESEARCH Q4's table now (invalid_state, code_expired, provider_error with :error, no_provider_id with PHP's hard-coded Polish text in both locales, account_not_activated, account_banned, registration_disabled, registration_throttled, consent_required, consent_expired, beta_key_invalid, account_already_linked with :provider, email_exists_link_account with :provider (pl = PHP's Polish text), invalid_provider, provider_not_configured with :provider, invalid_action, must_be_logged_in) plus `register.consent_required`, `register.terms_required`, `register.privacy_required` (pl = PHP's Polish texts byte for byte; en new, A6).
+
+classes/flow.go (tracer branch only, production code later tasks extend): `HandleCallback(ctx, app, r, provider string, profile Profile, st State) (Outcome, error)`; empty profile.ID gives FlowError no_provider_id; an identity row for (provider, profile.ID) loads its user (Unscoped, so a trashed user is found and restored by FinishLogin); the user must be activated (else account_not_activated) and have no user_throttle row with is_banned (else account_banned); then in one transaction `SELECT id FROM users WHERE id = ? FOR UPDATE` and update the identity's AccessToken, RefreshToken (lagoon.Encrypted), TokenExpiresAt (nil when ExpiresAt is zero), ProfileData {name, email, avatar} (D-19, avatar only here); after commit `userctrl.FinishLogin(ctx, app, db, user, userctrl.RemoteIP(r))`, `userctrl.IssueToken(app, user, r)`, `userctrl.APIArray(ctx, app, user)`; Outcome {Kind login, Token, User, Action "login", ReturnTo st.ReturnTo}. The database comes from the published `*gorm.DB` per request (Boot needs none, Pitfall 8).
+
+controllers (handler factories `func X(app *backpack.App) http.HandlerFunc`): `Redirect` for GET /oauth/{provider}: registry name unknown → `http.NotFound`; ResolveCredentials not enabled → 302 to the resolved callback with `?error=` + url.QueryEscape(provider_not_configured with the label) (completed in Task 3); action defaults to login; normalise callback and return_to; state = 32 random bytes base64url; `provider.Begin(state)`; SealState with action, callback, return_to and session; `Cache-Control: no-store`; 302 to authURL. `Callback` for GET /oauth/{provider}/callback: OpenState, then always set ClearStateCookie before writing anything; on an OpenState error, an empty query state or `subtle.ConstantTimeCompare` mismatch → 302 to the cookie's callback (or the default callback when the cookie did not open) with ?error=invalid_state message; a provider `error` query parameter → provider_error with "Authentication failed"; `provider.Complete` → `errors.Is(err, ErrCodeExpired)` gives code_expired, any other error provider_error "Authentication failed", never logging the error body; then HandleCallback; a login outcome → PutCompletion → 302 `{callback}?code=` + url.QueryEscape(code); a FlowError → 302 `{callback}?error=` + url.QueryEscape(message). `Complete` for GET /_user/api/v1/oauth-complete: empty `code` → 422 `{"error":"Missing OAuth completion code"}`; PullCompletion miss → 410 `{"error":"OAuth completion code is invalid or expired"}`; hit → 200 `{"token","user","action","return_to"}` with action default login and return_to default /; JSON through wire.WriteJSON with `Cache-Control: no-cache, private`.
+
+service.go and plugin.go (D-15, Pitfalls 7 and 8): `providerSource` implements `userclasses.OAuthProviderSource` (EnabledOAuthProviders: registry Names filtered by ResolveCredentials enabled, as {Name, Label}; KnownOAuthProviders: registry Names). Plugin: `PluginID = "golem15.sociallogin"`, Requires `golem15.user`, `//go:embed config` and `lang`, interface assertions for party.Plugin, pact.HasConfig, pact.HasLang, pact.HasRoutes; Register creates the Registry and publishes it (`app.Publish(reg)`) so a plugin that requires golem15.sociallogin can add a provider in its own Register; Boot runs LoadConfig (no DB, no cache) and `app.Publish[userclasses.OAuthProviderSource](src)` with the explicit type argument; `init() { party.Register(&Plugin{}) }`. routes.go (D-20): `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", ...)`. config.go `Config` and `LoadConfig` (keys and defaults in this plan's Decisions); config/config.yaml with the oauthserver header style naming the SUMMER_GOLEM15__SOCIALLOGIN__ form and the PHP env name above each key, providers block with empty values.
+
+Tests: testmain_test.go and harness_test.go copied from sm-oauthserver-plugin and renamed (`freshDB`, `bootSocial(t, env map[string]string) (*backpack.App, *httptest.Server)` activating golem15.user and golem15.sociallogin with the memory cooler and app.url equal to the test server URL; helpers for a user, a session token and a no-redirect browser with a cookie jar). fakeprovider_test.go: `fakeProvider` implementing classes.Provider; Begin returns `https://provider.example.test/authorize?state=` and a session string; Complete returns the Profile or error the test configured for the callback `code`. tracer_smoke_test.go `TestTracerSmoke` (Postgres): register `fake` with label `Fake` through RegistryFromApp, configure its client id and secret, seed a user and an identity (fake, fake-123); GET /oauth/fake?mode=spa&action=login&frontend_callback=/auth/oauth/callback&return_to=/dashboard answers 302 to provider.example.test and sets summer_oauth_fake with HttpOnly, SameSite=Lax, Path=/oauth, Max-Age=600; GET /oauth/fake/callback?code=ok&state= answers 302 to /auth/oauth/callback?code=<64 chars> and clears the cookie; GET /_user/api/v1/oauth-complete?code=… answers 200 with a token, user.email of the seeded user, action login, return_to /dashboard; the same code again answers 410; GET /_user/api/v1/oauth-providers lists exactly {name fake, label Fake}.
+
+README in the standard structure (H1 golem15.sociallogin, one-sentence summary, import line, module/package/plugin ID line, Overview, Features, Routes, Configuration, API reference, Dependencies, Testing), neutral wording ("the application"), stating what exists after this task. Commit: `feat: social sign-in tracer through a fake provider`.
+
+ go -C ../sm-sociallogin-plugin vet ./... && go -C ../sm-sociallogin-plugin test -count=1 -short ./... && go -C ../sm-sociallogin-plugin test -count=1 -run '^TestTracerSmoke$' -v . 2>&1 | grep -q -- '^--- PASS: TestTracerSmoke'
+ Non-zero exit: vet or a short test is red, or no "--- PASS: TestTracerSmoke" line (the end-to-end path failed, or the test skipped because Docker was unreachable: rerun through sudo -u golem, never record it as passed).
+
+
+ - `grep -c '^module git.golem15.com/golem15/sm-sociallogin-plugin$' ../sm-sociallogin-plugin/go.mod` prints at least 1 and `grep -c 'markbates/goth' ../sm-sociallogin-plugin/go.mod` prints 0.
+ - `grep -c 'Publish\[userclasses.OAuthProviderSource\]' ../sm-sociallogin-plugin/plugin.go ../sm-sociallogin-plugin/service.go | awk -F: '{s+=$2} END {print s}'` prints at least 1.
+ - `grep -c 'throttle:20,1' ../sm-sociallogin-plugin/routes.go` prints at least 1 and `grep -c 'throttle:user-api' ../sm-sociallogin-plugin/routes.go` prints at least 1.
+ - `grep -c 'golem15.sociallogin.oauth-state.v1' ../sm-sociallogin-plugin/classes/statecookie.go` prints at least 1 and `grep -c 'ConstantTimeCompare' ../sm-sociallogin-plugin/controllers/callback.go` prints at least 1.
+ - `grep -c '"github.com/markbates/goth' ../sm-sociallogin-plugin/classes/provider.go` prints 0 (no goth import path in the public types file).
+ - `grep -c '"jwks"' ../sm-sociallogin-plugin/classes/provider.go` prints at least 1.
+ - `R=$(git -C ../sm-sociallogin-plugin remote) && test -z "$R"` exits 0 (no remote yet).
+
+ A returning user's sign-in runs end to end through the plugin's own registry with a fake provider: redirect with a sealed state cookie, callback, one-time code, completion, and the login-button list.
+
+
+
+ Task 2: New users and unverified email matches: the PHP matching table, pending registration with its redirect parameters, account creation at the callback and at oauth-register-complete with consent, throttle and beta gate, and the opt-in token cookie
+ D-10: auto-linking by verified email merges accounts in a way that cannot be undone for accounts already merged; the user locked this rule in CONTEXT, so no checkpoint re-asks it.
+ ../sm-sociallogin-plugin/classes/flow.go, ../sm-sociallogin-plugin/classes/registration.go, ../sm-sociallogin-plugin/classes/codes.go, ../sm-sociallogin-plugin/classes/statecookie.go, ../sm-sociallogin-plugin/controllers/redirect.go, ../sm-sociallogin-plugin/controllers/callback.go, ../sm-sociallogin-plugin/controllers/complete.go, ../sm-sociallogin-plugin/controllers/register_complete.go, ../sm-sociallogin-plugin/routes.go, ../sm-sociallogin-plugin/config.go, ../sm-sociallogin-plugin/config/config.yaml, ../sm-sociallogin-plugin/lang/en/lang.yaml, ../sm-sociallogin-plugin/lang/pl/lang.yaml, ../sm-sociallogin-plugin/flow_smoke_test.go, ../sm-sociallogin-plugin/README.md
+ ../sm-sociallogin-plugin/classes/flow.go, ../sm-sociallogin-plugin/classes/codes.go, ../sm-sociallogin-plugin/controllers/callback.go (Task 1), 19.5-RESEARCH.md (Q4 Branches, Callback table, API endpoints table; Pitfalls 2, 10, 11, 14, 15; Open Questions 1-3), 19.5-CONTEXT.md (D-08 to D-12, D-22), /media/nvme/dev/jz/wavepath.org/plugins/golem15/user/components/SocialAuth.php (:284-633 login and register branches, :919-987 pending redirect, :995-1108 completePendingRegistration, :1253-1298 username), /media/nvme/dev/jz/wavepath.org/plugins/golem15/user/controllers/ApiController.php (:1097-1170), /media/nvme/dev/jz/wavepath.org/plugins/golem15/user/models/User.php (:822-862, :1073-1110), ../fonoteka.go/plugins/golem15/user/controllers/registration.go, ../fonoteka.go/plugins/golem15/user/classes/user_lookup.go (:116-129), ../fonoteka.go/plugins/golem15/user/models/throttle.go, modules/bouncer/cookie.go (:145-159)
+ Per D-08, D-09, D-10, D-11 (consumer side), D-12, D-19, D-22, in PHP branch order (RESEARCH Q4 Branches).
+
+Start request (controllers/redirect.go): for action=register store Consent in the cookie: terms_accepted, privacy_accepted, marketing_consent parsed as true for "1", "true", "on", "yes" (case-insensitive) and false otherwise; parental_authority only when the parameter is present; At = now; beta_key only when non-empty (into State.BetaKey).
+
+Matching (classes/flow.go `HandleCallback`), all branches: (1) identity (provider, profile.ID) → login. (2) empty profile.Email → pending reason `fb_no_email`. (3) a user whose `lower(email) = lower(?)` (Unscoped, so trashed accounts match; Pitfalls 10, 11): if `!profile.EmailVerified` → pending reason `unverified_match`; else link (upsert by (user_id, provider): provider_id, tokens, token_expires_at, profile_data {name, email, avatar}, linked_at now; an identity (provider, provider_id) owned by another user → FlowError account_already_linked) and login (FinishLogin restores a trashed account, Open Question 3). (4) no match: action login → pending with no reason; action register → create the user at the callback (below). Every login outcome checks activated and not banned first. The identity upsert and user creation run in one transaction that first takes `SELECT id FROM users WHERE id = ? FOR UPDATE` on the target user (Pitfall 1: adding paths take the same lock as unlink).
+
+Pending (classes/codes.go `PendingPayload`, JSON names exactly PHP's: provider, provider_id, token, refresh_token, expires_in, name, email, avatar, return_to, email_verified, reason, plus beta_key): `PutPending`, `GetPending`, `PullPending` (PendingTTL 10 min). The callback redirects to `{callback}?pending_registration=` + url.QueryEscape(code), then `&reason=` only when non-empty, then always `&provider=`, then `&email=` only when non-empty (PHP order :919-987).
+
+Creation (classes/registration.go `CreateUser`, both paths; D-10, D-22): allow_registration false → FlowError registration_disabled; use_register_throttle and `userctrl.RegistrationsFromIP(ctx, db, ip) >= 3` → registration_throttled; at the callback the cookie consent must have terms and privacy true and parental_authority true when it was sent (else consent_required) and be at most 600 s old (else consent_expired; Pitfall 15); beta gate when `golem15.sociallogin.beta_key` is non-empty: `subtle.ConstantTimeCompare` against State.BetaKey (else beta_key_invalid). Then `userctrl.RegisterUser` with fields email, name (profile name, else the email local part, else "User"; RegisterUser's AssignHandle derives the handle, D-22 maps PHP username to handle), password and password_confirmation set to the same 32 random bytes base64url, marketing_consent true when opted in; options Activate true, IP `userctrl.RemoteIP(r)`, PasswordLess true; insert the identity in the same transaction; after commit `userctrl.FireRegisterEvent` with the fields (password removed by it) plus terms_accepted, privacy_accepted, marketing_consent, parental_authority when sent, terms_version and privacy_version from config, consent_ip_address, consent_user_agent, oauth_provider and password_bootstrap_source `oauth_registration` (D-22: consent details go into RegisterEvent.Payload; no column, no group), then `userctrl.FireCreatedEvent`, then FinishLogin, IssueToken and APIArray; Outcome action `register`.
+
+POST /_user/api/v1/oauth-register-complete (controllers/register_complete.go, D-08 validate before pull, D-09 contract): read JSON or form fields pending_code, terms_accepted, privacy_accepted, marketing_consent, parental_authority (optional), beta_key (optional). Empty pending_code → 422 `{"error":"Missing pending registration code"}`. GetPending miss → 410 `{"error":"OAuth registration session is invalid or expired"}`. Terms or privacy not true → 422 `{"error": register.consent_required, "errors": {"terms_accepted": [register.terms_required] or [], "privacy_accepted": [register.privacy_required] or []}}` (wire.Slice so an empty list is `[]`), code kept. Beta gate when configured: payload beta_key or body beta_key must match in constant time, else 422 `{"error": beta_key_invalid}`, code kept (Pitfall 14). Decide the outcome on the payload: identity exists → login; email match with `email_verified` false or absent (fail closed, PHP `empty(...)`) → 422 `{"error": email_exists_link_account with :provider = provider label}`, code kept; email match verified → link and login; otherwise create (allow and throttle; consent from the request, no timestamp check). Only then `PullPending`; a miss → 410 (lost race). Then execute; 200 `{token, user, action (register default), return_to}`.
+
+D-09 cookie flag (controllers/complete.go and register_complete.go): when `golem15.sociallogin.token_cookie` is true, load `bouncer.LoadCookieConfig(app.Config)`, set `cfg.TokenCookie(token, userctrl.RefreshTTL(app))` and leave `token` out of the body; default off keeps PHP's body.
+
+Routes: add `g.Post("/oauth-register-complete", ...)` to the user-api group. README: Features and Routes gain pending registration, register-complete, the matching table, the beta gate and the token cookie option; Configuration gains token_cookie, beta_key, consent versions.
+
+Smoke test flow_smoke_test.go `TestFlowSmoke` (Postgres) with subtests: `unverified_match_pending` (fake profile with EmailVerified false and an existing user's email in different case → redirect has pending_registration, reason=unverified_match, provider=fake, email in that order; no identity created); `verified_match_links` (EmailVerified true → identity linked to the existing user, completion action login); `register_creates_passwordless` (action=register with terms and privacy 1 → user with has_self_set_password false and is_activated true, RegisterEvent payload contains terms_version and oauth_provider); `register_complete_keeps_code_on_422` (pending code, POST without terms → 422 with both errors keys; POST with terms and privacy → 200 action register; the same code again → 410); `token_cookie_mode` (token_cookie true → Set-Cookie token and no token key in the complete body). Commit: `feat: pending registration, account creation and email matching`.
+
+ go -C ../sm-sociallogin-plugin vet ./... && go -C ../sm-sociallogin-plugin test -count=1 -short ./... && go -C ../sm-sociallogin-plugin test -count=1 -run '^(TestTracerSmoke|TestFlowSmoke)$' -v . 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 2
+ Non-zero exit or the count of top-level "--- PASS: Test" lines is not 2: TestFlowSmoke failed or skipped (Docker unreachable: rerun through sudo -u golem), or the tracer regressed.
+
+
+ - `grep -c 'unverified_match' ../sm-sociallogin-plugin/classes/flow.go` prints at least 1 and `grep -c 'fb_no_email' ../sm-sociallogin-plugin/classes/flow.go` prints at least 1.
+ - `grep -ci 'lower(email)' ../sm-sociallogin-plugin/classes/flow.go` prints at least 1.
+ - `grep -c 'PasswordLess' ../sm-sociallogin-plugin/classes/registration.go` prints at least 1 and `grep -c 'password_bootstrap_source' ../sm-sociallogin-plugin/classes/registration.go` prints at least 1.
+ - `grep -c 'GetPending' ../sm-sociallogin-plugin/controllers/register_complete.go` prints at least 1 and `grep -c 'PullPending' ../sm-sociallogin-plugin/controllers/register_complete.go` prints at least 1.
+ - `grep -c 'ConstantTimeCompare' ../sm-sociallogin-plugin/classes/registration.go ../sm-sociallogin-plugin/controllers/register_complete.go | awk -F: '{s+=$2} END {print s}'` prints at least 2.
+ - `grep -c 'Musisz zaakceptować regulamin i politykę prywatności, aby dokończyć rejestrację.' ../sm-sociallogin-plugin/lang/pl/lang.yaml` prints at least 1.
+ - `grep -c 'TokenCookie' ../sm-sociallogin-plugin/controllers/complete.go` prints at least 1.
+
+ A first-time social user either gets an account (register path, or after accepting consent on the pending screen) or is sent to the pending screen, an unverified email never merges into an existing account, and a 422 lets the user fix the checkboxes and retry with the same code.
+
+
+
+ Task 3: Existing users link a provider (bearer, cookie or one-time link ticket), and every bad start request lands on the SPA with a readable error: unknown and reserved names, disabled providers, invalid actions, bad callbacks
+ D-23: POST /_user/api/v1/oauth-link-ticket and the link_ticket query parameter become a client contract that SPAs build against.
+ ../sm-sociallogin-plugin/controllers/redirect.go, ../sm-sociallogin-plugin/controllers/callback.go, ../sm-sociallogin-plugin/controllers/link_ticket.go, ../sm-sociallogin-plugin/classes/flow.go, ../sm-sociallogin-plugin/classes/codes.go, ../sm-sociallogin-plugin/classes/callback.go, ../sm-sociallogin-plugin/routes.go, ../sm-sociallogin-plugin/config.go, ../sm-sociallogin-plugin/config/config.yaml, ../sm-sociallogin-plugin/lang/en/lang.yaml, ../sm-sociallogin-plugin/lang/pl/lang.yaml, ../sm-sociallogin-plugin/link_smoke_test.go, ../sm-sociallogin-plugin/README.md
+ ../sm-sociallogin-plugin/controllers/redirect.go, ../sm-sociallogin-plugin/classes/flow.go (Tasks 1-2), ../fonoteka.go/plugins/golem15/user/classes/session_user.go (RequestUser :34-79), ../fonoteka.go/plugins/golem15/user/controllers/api_controller.go (writeUnauthorized :1038), 19.5-CONTEXT.md (D-04, D-05, D-06, D-14, D-17, D-20, D-23), 19.5-RESEARCH.md (Open Questions 4 and 7, Pitfall 9, Q4 Branches "Link"), /media/nvme/dev/jz/wavepath.org/plugins/golem15/user/components/SocialAuth.php (:111-191 redirect, :638-706 link, :779-794 resolveAuthenticatedUser, :1185-1251 normalizers)
+ Per D-04, D-05, D-06, D-14, D-17, D-20, D-23.
+
+Start request validation (controllers/redirect.go), in this order: registry name unknown (including reserved names, which Register already refuses) → `http.NotFound`, no cookie; resolve the callback (D-17 normaliser, else default_callback per D-05); `mode` is read and ignored (SPA only); action not in login, register, link → 302 `{callback}?error=` invalid_action message; provider not enabled → 302 with provider_not_configured (`:provider` = label). Messages go through url.QueryEscape (PHP urlencode).
+
+Link start (D-14, D-23): for action=link resolve the user from `link_ticket` first (`PullLinkTicket`; a missing, used or expired ticket → must_be_logged_in), else `userclasses.RequestUser(ctx, app, r)` (bearer, then the token or auth_token cookie, with blacklist and tokens_valid_after); none → 302 with must_be_logged_in. Store the user id in State.LinkUserID; no JWT ever goes into a URL.
+
+Link callback (classes/flow.go, PHP :638-706): action link loads the user from State.LinkUserID (not soft-deleted, activated, not banned; otherwise must_be_logged_in); empty profile ID → no_provider_id; an identity (provider, provider_id) owned by another user → account_already_linked; otherwise upsert by (user_id, provider) under the user-row lock (D-19 fields), IssueToken for that user, completion action `link`, 302 `?code=`.
+
+Link ticket endpoint (controllers/link_ticket.go, D-23): `POST /_user/api/v1/oauth-link-ticket` in the user-api group; the caller comes from `userclasses.RequestUser`; none → 401 with the user plugin's body `{"error":true,"message":"Unauthorized"}`; otherwise `PutLinkTicket` (key `oauth-link-ticket:` + code, sealed user id, LinkTicketTTL 60 s) and 200 `{"link_ticket":"<64 chars>","expires_in":60}` with `Cache-Control: no-store`.
+
+Callback-side: when the cookie opened, every error goes to its callback; otherwise to default_callback. Config: callback_path, allowed_hosts, frontend_url, default_callback are read by LoadConfig (present since Task 1; this task makes sure each one is used and documented). README: Routes (link_ticket parameter, oauth-link-ticket endpoint, error redirects), Configuration (allowed_hosts for split www and api domains, callback_path), a short "Linking from a bearer-token SPA" section (POST the ticket, then navigate to /oauth/{p}?action=link&link_ticket=…&mode=spa&frontend_callback=…).
+
+Smoke test link_smoke_test.go `TestLinkSmoke` (Postgres) with subtests: `ticket_links` (bearer POST oauth-link-ticket → ticket; /oauth/fake?action=link&link_ticket=… → callback → complete has action link and the identity belongs to that user); `ticket_single_use` (second use → error=must_be_logged_in message); `ticket_needs_auth` (no bearer → 401 body above); `bearer_header_link` (Authorization bearer on the start request links without a ticket); `owned_by_other` (identity already on another user → error=account_already_linked); `unknown_and_reserved_404` (/oauth/nope and /oauth/jwks answer 404 when oauthserver is not mounted); `disabled_provider_error` (registered provider without credentials → 302 to the callback with ?error=); `bad_callback_falls_back` (frontend_callback https://evil.example.test/auth/oauth/callback → errors and codes go to default_callback). Commit: `feat: identity linking with a one-time link ticket and start-request errors`.
+
+ go -C ../sm-sociallogin-plugin vet ./... && go -C ../sm-sociallogin-plugin test -count=1 -short ./... && go -C ../sm-sociallogin-plugin test -count=1 -run '^(TestTracerSmoke|TestFlowSmoke|TestLinkSmoke)$' -v . 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 3
+ Non-zero exit or the count of top-level "--- PASS: Test" lines is not 3: TestLinkSmoke failed or skipped, or an earlier slice regressed.
+
+
+ - `grep -c 'RequestUser' ../sm-sociallogin-plugin/controllers/redirect.go ../sm-sociallogin-plugin/controllers/link_ticket.go | awk -F: '{s+=$2} END {print s}'` prints at least 2.
+ - `grep -c 'oauth-link-ticket' ../sm-sociallogin-plugin/routes.go ../sm-sociallogin-plugin/classes/codes.go | awk -F: '{s+=$2} END {print s}'` prints at least 2.
+ - `grep -c 'account_already_linked' ../sm-sociallogin-plugin/classes/flow.go` prints at least 1.
+ - `grep -rn '"token"' ../sm-sociallogin-plugin/controllers/redirect.go | grep -c 'Query'` prints 0 (no token query parameter is read).
+ - `grep -c 'allowed_hosts' ../sm-sociallogin-plugin/config/config.yaml` prints at least 1 and `grep -c 'link_ticket' ../sm-sociallogin-plugin/README.md` prints at least 1.
+
+ An existing user, including one whose SPA keeps the JWT in localStorage, can attach a provider without a JWT in any URL, and every malformed start request ends on the SPA with a readable error instead of an error page.
+
+
+
+ Task 4: Real sign-in with Google, Facebook, GitHub and Microsoft through goth (no gothic), with the PHP email-verified rules and the admin settings screen that overrides config without a restart
+ 19.5-01-SUMMARY.md records the Task 4 reply "approved" for github.com/markbates/goth v1.82.0; if it records "rejected" or nothing, stop before go get and report.
+ D-16: the golem15_sociallogin_settings table and migration ID 202610200001_create_golem15_sociallogin_settings are recorded in every application that migrates them; renaming later needs a migration.
+ ../sm-sociallogin-plugin/go.mod, ../sm-sociallogin-plugin/go.sum, ../sm-sociallogin-plugin/classes/providers_goth.go, ../sm-sociallogin-plugin/classes/credentials.go, ../sm-sociallogin-plugin/plugin.go, ../sm-sociallogin-plugin/config.go, ../sm-sociallogin-plugin/admin_settings.go, ../sm-sociallogin-plugin/admin_permissions.go, ../sm-sociallogin-plugin/models/registry.go, ../sm-sociallogin-plugin/models/settings.go, ../sm-sociallogin-plugin/models/settings/fields.yaml, ../sm-sociallogin-plugin/updates/registry.go, ../sm-sociallogin-plugin/updates/202610200001_create_golem15_sociallogin_settings.go, ../sm-sociallogin-plugin/config/config.yaml, ../sm-sociallogin-plugin/lang/en/lang.yaml, ../sm-sociallogin-plugin/lang/pl/lang.yaml, ../sm-sociallogin-plugin/providers_smoke_test.go, ../sm-sociallogin-plugin/settings_smoke_test.go, ../sm-sociallogin-plugin/README.md
+ .planning/notes/2026-10-20-sociallogin-goth.md, 19.5-RESEARCH.md (Q1, Q2, Q3, Code Example 1, Pitfalls 3, 4, 5, 6, State of the Art), the goth v1.82.0 sources in the module cache after go get (providers/google/google.go and session.go, providers/github/github.go :143-233, providers/facebook/facebook.go :22-113, providers/azureadv2/azureadv2.go :51-223 and scopes.go), ../sm-pgstripe-plugin/models/settings.go, ../sm-pgstripe-plugin/models/settings/fields.yaml, ../sm-pgstripe-plugin/admin.go, ../sm-pgstripe-plugin/updates/202610130001_create_golem15_pgstripe_settings.go, ../sm-pgstripe-plugin/updates/registry.go, ../sm-oauthserver-plugin/admin_permissions.go, ../sm-herald-plugin/boot.go (:47-64), modules/cabana/settings_secret.go, modules/pact/capabilities.go (:567-591)
+ Install (D-02): in ../sm-sociallogin-plugin run `go get github.com/markbates/goth@v1.82.0` and `go mod tidy`; commit go.mod and go.sum with the code below.
+
+classes/providers_goth.go (the only file importing goth; never goth's session-store helper subpackage and never goth's package-global provider map, see the decision note; keep such warnings out of Go comments, the acceptance grep checks the identifiers): `RegisterBuiltins(reg *Registry) error` registers google "Google", facebook "Facebook", github "GitHub", microsoft "Microsoft"; Plugin.Register calls it before publishing the registry. One unexported wrapper type implements classes.Provider over a goth.Provider: Begin = `BeginAuth(state)`, `GetAuthURL()`, `Marshal()`; Complete = `UnmarshalSession`, `Authorize(gp, callback)`, `FetchUser`; each provider's exported HTTPClient field gets ProviderConfig.HTTPClient (goth has no timeout by default, RESEARCH Q3); an `*oauth2.RetrieveError` whose ErrorCode is invalid_grant or bad_verification_code is wrapped with `ErrCodeExpired`; no error body is logged. Per provider (D-04, D-11):
+- google: `google.New(id, secret, redirect, "openid", "email", "profile")`, `SetPrompt("select_account")`; EmailVerified = RawData `verified_email` or `email_verified` that is a bool true (strict type assertion; Pitfall 4).
+- facebook: `SetCustomFields` id, name, email, first_name, last_name, picture; EmailVerified always false; no Graph version pinned (Pitfall 6).
+- github: scopes `read:user`, `user:email`; ignore goth's Email: after Authorize call `https://api.github.com/user/emails` with the access token through the same HTTPClient and take the address with primary and verified true; `ErrNoVerifiedGitHubPrimaryEmail` from FetchUser is not an error (keep the returned user's ID, empty email → fb_no_email pending); EmailVerified = email non-empty (Pitfall 3, D-11 PHP rationale).
+- microsoft: `azureadv2.New(id, secret, redirect, azureadv2.ProviderOptions{Tenant: azureadv2.TenantType(tenant or "common"), Scopes: OpenIDScope, ProfileScope, EmailScope, OfflineAccessScope, UserReadScope})`; the wrapper reports name microsoft; Email = Graph `mail`, else RawData `userPrincipalName` when it contains @ (Open Question 5); AvatarURL empty (goth's Graph photo URL needs the bearer token; Pitfall 5); EmailVerified always false (D-11, the nOAuth class).
+
+Settings (D-16): migration `202610200001_create_golem15_sociallogin_settings` creating `golem15_sociallogin_settings` (id INTEGER PRIMARY KEY, google_client_id, google_client_secret, facebook_client_id, facebook_client_secret, github_client_id, github_client_secret, microsoft_client_id, microsoft_client_secret, microsoft_tenant, all TEXT NULL, created_at and updated_at TIMESTAMP NULL) and inserting row id 1; Rollback drops it; updates/registry.go as in pgstripe. models.Settings: `*string` client ids and tenant, `lagoon.Encrypted` secrets with `json:"-"`, TableName, Fillable, `SecretFallbacks()` mapping each `
_client_secret` column to `golem15.sociallogin.providers.
.client_secret`, `var _ cabana.SecretFallbacks = Settings{}`, `LoadSettings(ctx, db)` returning row 1 or a zero Settings; models/registry.go as pgstripe. models/settings/fields.yaml: one tab per provider, client ids `type: text` with autocomplete off, secrets `type: password` (write-only), microsoft_tenant `type: text` with the comment common, organizations or a tenant ID. admin_settings.go: `AdminFS`, `Settings()` returning one pact.SettingsItem (Code SettingsCode, lang Label, Description, Category, Icon key, Order 530, Permissions [PermissionAccessSettings], Form models/settings/fields.yaml, NewModel); admin_permissions.go: `Permissions()` with PermissionAccessSettings like sm-oauthserver-plugin. plugin.go adds the assertions pact.HasMigrations, pact.HasModels, pact.HasSettings, pact.HasPermissions and the AdminFS embed.
+
+Credentials (classes/credentials.go): `ResolveCredentials` now overlays, per request, the settings row from the published `*gorm.DB` (absent DB or table → config only): client_id = settings value when non-empty else config; secret = decrypted settings value when non-empty else config; tenant = settings, else config, else common; enabled only when id and secret are non-empty; nothing cached across requests, so an admin change applies on the next request. Secrets are never logged.
+
+Legacy env (plugin.go Boot, sm-herald-plugin copyEnv pattern): copy each env name in this plan's Decisions onto its empty config key. config/config.yaml documents every provider key with its env names. README: Features (four providers, verified rules table from D-11), Configuration (provider keys, env names, tenant, redirect override, settings override), Admin (the settings screen, write-only secrets), Dependencies (goth v1.82.0 core and four provider packages, never gothic; golang.org/x/oauth2), Security notes (nOAuth: Microsoft never auto-links; multi-instance apps need the Redis cache driver for the codes).
+
+Smoke tests: providers_smoke_test.go `TestGothProvidersSmoke` (no network, runs under -short): an http.RoundTripper fake set through ProviderConfig.HTTPClient answers the token endpoints and profile APIs by host (oauth2.googleapis.com, www.googleapis.com, graph.facebook.com, github.com, api.github.com, login.microsoftonline.com, graph.microsoft.com); subtests: google verified_email true gives EmailVerified true and false gives false; github /user/emails primary verified address is the Email and EmailVerified true, none gives empty Email and no error; microsoft mail equal to some address gives EmailVerified false and an empty mail falls back to userPrincipalName; the token endpoint answering invalid_grant gives an error matching ErrCodeExpired; Begin's authURL for microsoft with tenant organizations contains /organizations/oauth2/v2.0/authorize and offline_access. settings_smoke_test.go `TestSettingsOverrideSmoke` (Postgres): config-only credentials enable a provider (oauth-providers lists it); a settings row with a different client id is used on the next request without reboot; a stored secret never appears in the admin settings JSON; clearing the configured secret and the row secret disables the provider. Commit: `feat: Google, Facebook, GitHub and Microsoft through goth with a settings override`.
+
+ go -C ../sm-sociallogin-plugin vet ./... && go -C ../sm-sociallogin-plugin test -count=1 -short ./... && go -C ../sm-sociallogin-plugin test -count=1 -short -run '^TestGothProvidersSmoke$' -v . 2>&1 | grep -q -- '^--- PASS: TestGothProvidersSmoke'
+ Non-zero exit: vet or a short test is red, or no "--- PASS: TestGothProvidersSmoke" line (a wrapper maps a field or an error wrongly).
+ go -C ../sm-sociallogin-plugin test -count=1 -run '^(TestTracerSmoke|TestFlowSmoke|TestLinkSmoke|TestSettingsOverrideSmoke)$' -v . 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 4
+ The count of top-level "--- PASS: Test" lines is not 4: a Postgres-backed smoke failed or skipped (Docker unreachable: rerun through sudo -u golem, else record NOT RUN).
+
+
+ - `grep -c 'github.com/markbates/goth v1.82.0' ../sm-sociallogin-plugin/go.mod` prints at least 1.
+ - `G=$(grep -rln 'github.com/markbates/goth' --include='*.go' --exclude='*_test.go' ../sm-sociallogin-plugin) && test "$G" = "../sm-sociallogin-plugin/classes/providers_goth.go"` exits 0 (exactly one non-test Go file imports goth).
+ - `grep -rn 'markbates/goth/gothic\|UseProviders' --include='*.go' ../sm-sociallogin-plugin | grep -c .` prints 0.
+ - `D=$(go -C ../sm-sociallogin-plugin list -deps .) && test "$(printf '%s\n' "$D" | grep -c 'gorilla/sessions')" -eq 0` exits 0.
+ - `D=$(go -C ../sm-sociallogin-plugin list -deps .) && test "$(printf '%s\n' "$D" | grep -c '^gopkg.in/yaml.v3$')" -eq 0` exits 0.
+ - `grep -c 'verified_email' ../sm-sociallogin-plugin/classes/providers_goth.go` prints at least 1 and `grep -c 'userPrincipalName' ../sm-sociallogin-plugin/classes/providers_goth.go` prints at least 1 and `grep -c 'user/emails' ../sm-sociallogin-plugin/classes/providers_goth.go` prints at least 1.
+ - `grep -c '202610200001_create_golem15_sociallogin_settings' ../sm-sociallogin-plugin/updates/202610200001_create_golem15_sociallogin_settings.go` prints at least 1 and `grep -c 'type: password' ../sm-sociallogin-plugin/models/settings/fields.yaml` prints at least 4.
+
+ The four real providers sign users in through the same flow as the fake one, Microsoft and Facebook never auto-link by email, and an administrator can change credentials in the admin without a restart.
+
+
+
+ Task 5: Go-ahead for the phase's remote operations in D-21 order (framework tag and changelog, sm-user-plugin push and tag, new sm-sociallogin-plugin repository, plan 03's pushes)
+ 19.5-CONTEXT.md (D-21), 19.5-01-SUMMARY.md, CLAUDE.md (Commits and concurrency, changelog rule), ../sm-summercmsio-app/DEPLOY.md ("Posting to the changelog")
+
+ - The SUMMARY records the reply verbatim under a "Task 5 reply" heading, and the chosen framework tag when the reply is release.
+ - Before the reply, `R=$(git -C ../sm-sociallogin-plugin remote) && test -z "$R"` exits 0 and `T=$(git tag --points-at HEAD) && test -z "$T"` exits 0 in summercms.go.
+
+ Which remote operations may this phase perform, and with which framework tag?
+ Tasks 1-4 are committed locally and green. Plan 03 mounts the plugin as a git submodule in sm-demo-app and sm-bm-app, which needs the remote repository to exist, and bumps sm-user-plugin pointers, which must reference pushed commits (CLAUDE.md: commit and push the plugin first, then the pointer bump). D-08 says the plugin pins a framework release that contains cooler.Cache.Pull. Facts to weigh: summercms.go master is about 40 commits ahead of origin/master and ahead of v0.2.4 (mostly Phase 19.4's OIDC work, plus this phase's Pull), so pushing master and tagging publishes all of them, and the tag needs a changelog post the same day (scripts/changelog-post.py in ../sm-summercmsio-app; the token file must exist). sm-user-plugin would get this phase's commits on origin/master and a tag (proposed v0.1.2). The new repository is created with `tea repos create --owner golem15 --name sm-sociallogin-plugin` (Phase 19.1 precedent) and tagged v0.1.0. Nothing is deployed in any option.
+
+
+
+
+
+ Reply "release vX.Y.Z", "push-only" or "hold". release and push-only also authorise plan 03 to push sm-bm-plugin before its pointer bump and to push sm-demo-app, sm-bm-app and fonoteka.go after their commits, each only when its unpushed commits are this phase's; nothing is ever force-pushed or deployed.
+
+
+
+ Task 6: Under the Task 5 reply, publish in D-21 order: framework, sm-user-plugin, then the new plugin repository pinned to the released versions
+ 19.5-02-SUMMARY.md (draft) records the Task 5 reply "release vX.Y.Z" or "push-only"; on "hold" this task records "remote operations held" and does nothing else.
+ ../sm-sociallogin-plugin/go.mod, ../sm-sociallogin-plugin/go.sum, ../sm-sociallogin-plugin/README.md
+ 19.5-02-SUMMARY.md draft (Task 5 reply), CLAUDE.md (Commits and concurrency), ../sm-summercmsio-app/DEPLOY.md ("Posting to the changelog"), ../sm-summercmsio-app/scripts/changelog-post.py (usage header), .planning/STATE.md decisions for Phase 19.1 (tea repos create precedent), ../sm-oauthserver-plugin/go.mod
+ Per D-21 and the recorded reply. Before every push: `git status` shows no uncommitted change to this phase's paths, `git log --oneline origin/master..master` (after `git fetch origin`) lists only commits the user approved (for summercms.go: the commits ahead of origin named in the Task 5 context; for the plugins: this phase's commits from the 19.5-01 and 19.5-02 SUMMARYs); if anything else appears, stop and report. Never force-push. Never deploy.
+
+On "release vX.Y.Z": (1) summercms.go: `go vet ./...` and `go test -short ./...` green; annotated tag vX.Y.Z on master HEAD with a one-paragraph message (cooler.Cache.Pull and what else the commits since the previous tag contain); `git push origin master` then `git push origin vX.Y.Z`; from ../sm-summercmsio-app run `scripts/changelog-post.py source vX.Y.Z`, write user-facing notes (## Highlights, ## Added, ## Changed, ## Fixed; no H1, no planning IDs, no quick codes) to a file in the session scratchpad, then `scripts/changelog-post.py publish vX.Y.Z --excerpt ""`; if the token file is missing or refused, ask the user and do not print or mint a token. (2) sm-user-plugin (../fonoteka.go/plugins/golem15/user): `git push origin master`, annotated tag v0.1.2, push the tag. (3) sm-sociallogin-plugin: in go.mod require `git.golem15.com/golem15/summercms vX.Y.Z` and `git.golem15.com/golem15/sm-user-plugin v0.1.2` (keep the go.mod replace of summercms to ../summercms.go, the oauthserver precedent), `go mod tidy`, vet and short tests green, README Dependencies names both versions; commit `chore: require summercms vX.Y.Z and sm-user-plugin v0.1.2`; `tea repos create --owner golem15 --name sm-sociallogin-plugin` (private unless the user said otherwise), `git remote add origin git@git.golem15.com:golem15/sm-sociallogin-plugin.git`, `git push -u origin master`, annotated tag v0.1.0, push it.
+
+On "push-only": steps (1) and (2) without tags and without the changelog post (push master only), step (3) without changing the requires (record the D-08 deviation), creating the repository and pushing master without a tag.
+
+Record in the SUMMARY: each pushed commit range, each tag, the changelog post URL, the repository URL.
+
+ go -C ../sm-sociallogin-plugin vet ./... && go -C ../sm-sociallogin-plugin test -count=1 -short ./... && git -C ../sm-sociallogin-plugin ls-remote --exit-code origin refs/heads/master
+ Non-zero exit: the plugin no longer builds or passes after the require bump, or ls-remote exits 2 or 128 because origin/master does not exist (the repository was not created or not pushed). On "hold" this command is not run and the SUMMARY says so.
+
+
+ - On release: `git ls-remote --exit-code --tags origin vX.Y.Z` exits 0 in summercms.go, `git -C ../fonoteka.go/plugins/golem15/user ls-remote --exit-code --tags origin v0.1.2` exits 0, `git -C ../sm-sociallogin-plugin ls-remote --exit-code --tags origin v0.1.0` exits 0, and `grep -c 'git.golem15.com/golem15/sm-user-plugin v0.1.2' ../sm-sociallogin-plugin/go.mod` prints at least 1.
+ - On push-only: `git -C ../sm-sociallogin-plugin ls-remote --exit-code origin refs/heads/master` exits 0 and the SUMMARY records the D-08 deviation.
+ - `L=$(git -C ../fonoteka.go/plugins/golem15/user log --oneline origin/master..master) && test -z "$L"` exits 0 on release and push-only (sm-user-plugin fully pushed before plan 03 bumps pointers).
+
+ The framework, sm-user-plugin and the new plugin are on git.golem15.com in D-21 order (tagged on release), so applications can mount the plugin as a submodule.
+
+
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+|----------|-------------|
+| browser to /oauth/{provider} | untrusted query parameters: callback, return_to, action, consent, beta key, link ticket |
+| provider to /oauth/{provider}/callback | code and state arrive through the user's browser and can be replayed or injected |
+| plugin to provider APIs | token exchange and profile fetch over the internet; provider-asserted identity and email |
+| SPA to /_user/api/v1/oauth-* | one-time codes and pending codes presented by any client |
+| plugin to cooler and to the cookie | codes at rest in Redis or memory; state at rest in the browser |
+| administrator to the settings screen | client secrets entered and stored |
+
+## STRIDE Threat Register
+
+| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
+|-----------|----------|-----------|----------|-------------|-----------------|
+| T-19.5-07 | Spoofing | Callback (login CSRF, injected code) | high | mitigate | per-browser sealed state cookie, empty state rejected (unlike gothic), subtle.ConstantTimeCompare against the query state, provider bound in the cookie, cookie cleared on every outcome; TestTracerSmoke now, TestStateCookie in plan 04 |
+| T-19.5-08 | Tampering | SealState and OpenState | high | mitigate | AES-256-GCM with a fresh nonce under an HKDF key with a purpose label separate from lagoon's, AAD the cookie name, version and iat checked (600 s), sealed size bound 3800 bytes |
+| T-19.5-09 | Tampering | NormalizeFrontendCallback, NormalizeReturnTo | high | mitigate | exact-host allow-list (no wildcards), fixed callback path, target rebuilt from scheme, host and path without userinfo, query or fragment; return_to local only with //, /\, backslash, control characters and over-long values rejected; invalid input falls back to default_callback |
+| T-19.5-10 | Elevation of privilege | HandleCallback email matching (account takeover through an unverified or attacker-set email) | critical | mitigate | D-11 decided in each provider wrapper: Google only on a boolean verified flag, GitHub only the primary verified address from /user/emails, Facebook and Microsoft never (nOAuth); unverified matches go to pending unverified_match; case-insensitive match prevents near-duplicate accounts |
+| T-19.5-11 | Elevation of privilege | RegisterComplete (pending registration hijack) | high | mitigate | completion re-checks the identity and the email match on the payload; email_verified absent or false gives 422 email_exists_link_account and never a login; the payload is sealed in the cache |
+| T-19.5-12 | Spoofing | PullCompletion, PullPending, PullLinkTicket (code replay or race) | high | mitigate | 64-character crypto/rand codes, consumed with cooler.Cache.Pull, TTLs 5 min, 10 min and 60 s; register-complete validates before pulling and a lost race answers 410 |
+| T-19.5-13 | Elevation of privilege | beta gate (D-12) | medium | mitigate | constant-time comparison on both creation paths; the start request's key travels in the sealed cookie into the pending payload (closes PHP's pending-path bypass, Pitfall 14) |
+| T-19.5-14 | Spoofing | action=link and LinkTicket (linking a provider to the wrong account) | high | mitigate | the link user comes from RequestUser (blacklist and tokens_valid_after) or a ticket issued only to an authenticated POST, single use, 60 s; the user id is sealed in the cookie; an identity owned by another user is refused |
+| T-19.5-15 | Information disclosure | URLs and logs | high | mitigate | no JWT in any URL (link ticket instead of ?token=); the access log records no query strings; no code logs tokens, secrets, codes, tickets or RetrieveError bodies |
+| T-19.5-16 | Information disclosure | client secrets and provider tokens at rest | high | mitigate | secrets in lagoon.Encrypted columns, write-only type: password fields, never returned by the admin JSON (TestSettingsOverrideSmoke); provider tokens in the identity row's encrypted columns; cache values sealed |
+| T-19.5-17 | Denial of service | provider HTTP calls and /oauth routes | medium | mitigate | every goth provider gets an http.Client with a 10 s timeout (configurable); throttle:20,1 on /oauth and throttle:user-api on the API routes |
+| T-19.5-18 | Tampering | route shadowing with sm-oauthserver-plugin | medium | mitigate | reserved provider names refused by Registry.Register; unknown names answer 404, so GET /oauth/token or /oauth/mcp/... never reach a provider; plan 03 checks the coexistence in sm-demo-app |
+| T-19.5-19 | Elevation of privilege | login of inactive or banned users | medium | mitigate | every login, link and creation outcome checks is_activated and user_throttle.is_banned first; soft-deleted accounts are restored only on identity or verified-email matches |
+| T-19.5-20 | Denial of service | account creation flood | medium | mitigate | allow_registration and the per-IP register throttle (3 per hour) on both creation paths |
+| T-19.5-21 | Repudiation | consent records | low | mitigate | consent flags, policy versions, IP and user agent in RegisterEvent.Payload for the application to store; marketing_consent column set on opt-in |
+| T-19.5-22 | Spoofing | stale consent | low | mitigate | consent at the callback must be at most 600 s old (consent_expired), as PHP |
+| T-19.5-23 | Tampering | remote operations (Task 6) | medium | mitigate | blocking decision first; only approved commit ranges pushed after fetch and comparison; never force; no deploy |
+| T-19.5-SC | Tampering | Go module installs | high | mitigate | goth v1.82.0 installed only after plan 01 Task 4's recorded human approval (precondition); only classes/providers_goth.go imports it; go list -deps shows no gorilla/sessions and no gopkg.in/yaml.v3 |
+
+
+
+- Task 1: TestTracerSmoke on Postgres plus vet and the short suite.
+- Task 2: TestFlowSmoke and TestTracerSmoke.
+- Task 3: TestLinkSmoke, TestFlowSmoke and TestTracerSmoke.
+- Task 4: TestGothProvidersSmoke (short), the four Postgres smokes, the dependency-graph checks.
+- Task 5: reply recorded. Task 6: ls-remote checks for the approved operations.
+- Feedback sampling (19.5-VALIDATION.md): after each commit `go -C ../sm-sociallogin-plugin vet ./... && go -C ../sm-sociallogin-plugin test -short ./...`.
+
+
+
+- SC-1: /oauth/google, /oauth/facebook, /oauth/github and /oauth/microsoft with their callbacks at PHP's paths.
+- SC-2: SPA code handoff through oauth-complete and pending registration through oauth-register-complete.
+- SC-3: identities linked on login (verified matches) and through action=link.
+- D-02 and D-21: goth installed after the human check; remote operations only under the Task 5 reply, in D-21 order.
+
+
+
diff --git a/.planning/phases/19.5-social-login-in-sm-user-plugin/19.5-03-PLAN.md b/.planning/phases/19.5-social-login-in-sm-user-plugin/19.5-03-PLAN.md
new file mode 100644
index 00000000..815eeb81
--- /dev/null
+++ b/.planning/phases/19.5-social-login-in-sm-user-plugin/19.5-03-PLAN.md
@@ -0,0 +1,341 @@
+---
+phase: 19.5-social-login-in-sm-user-plugin
+plan: 03
+type: execute
+wave: 3
+depends_on: ["19.5-02"]
+files_modified:
+ - ../sm-demo-app/.gitmodules
+ - ../sm-demo-app/plugins/golem15/sociallogin
+ - ../sm-demo-app/plugins/golem15/user
+ - ../sm-demo-app/go.work
+ - ../sm-demo-app/summer.yaml
+ - ../sm-demo-app/plugins.gen.go
+ - ../sm-demo-app/boot_test.go
+ - ../sm-demo-app/sociallogin_test.go
+ - ../sm-demo-app/deploy/env.example
+ - ../sm-demo-app/README.md
+ - ../sm-bm-app/.gitmodules
+ - ../sm-bm-app/plugins/golem15/sociallogin
+ - ../sm-bm-app/plugins/golem15/user
+ - ../sm-bm-app/plugins/jz/bm
+ - ../sm-bm-app/plugins/jz/bm/static.go
+ - ../sm-bm-app/plugins/jz/bm/static_test.go
+ - ../sm-bm-app/plugins/jz/bm/public
+ - ../sm-bm-app/go.mod
+ - ../sm-bm-app/go.sum
+ - ../sm-bm-app/go.work
+ - ../sm-bm-app/summer.yaml
+ - ../sm-bm-app/plugins.gen.go
+ - ../sm-bm-app/sociallogin_test.go
+ - ../sm-bm-app/config/golem15.sociallogin.yaml
+ - ../sm-bm-app/deploy/env.example
+ - ../sm-bm-app/DEPLOY.md
+ - ../sm-bm-app/README.md
+ - ../sm-bm-app/frontend/vite.config.ts
+ - ../sm-bm-app/frontend/src/router.ts
+ - ../sm-bm-app/frontend/src/api/user.ts
+ - ../sm-bm-app/frontend/src/stores/auth.ts
+ - ../sm-bm-app/frontend/src/lib/oauth.ts
+ - ../sm-bm-app/frontend/src/lib/oauth.test.ts
+ - ../sm-bm-app/frontend/src/views/LoginView.vue
+ - ../sm-bm-app/frontend/src/views/ProfileView.vue
+ - ../sm-bm-app/frontend/src/views/OAuthCallbackView.vue
+ - ../sm-bm-app/frontend/src/views/OAuthRegisterView.vue
+ - ../fonoteka.go/plugins/golem15/user
+autonomous: true
+requirements: [SC-1, SC-3, D-01, D-04, D-05, D-06, D-11, D-14, D-17, D-18, D-21, D-23, D-24]
+user_setup:
+ - service: microsoft-entra
+ why: "Microsoft 365 sign-in in sm-bm-app (live use only; the tests need no credentials)"
+ env_vars:
+ - name: MICROSOFT_CLIENT_ID
+ source: "Entra admin center -> App registrations -> the bm app -> Overview (Application (client) ID)"
+ - name: MICROSOFT_CLIENT_SECRET
+ source: "Entra admin center -> App registrations -> the bm app -> Certificates & secrets"
+ - name: MICROSOFT_TENANT
+ source: "common, organizations, or the university's Directory (tenant) ID"
+ dashboard_config:
+ - task: "Register a web redirect URI https://bm.jakubzych.com/oauth/microsoft/callback with delegated permissions openid, profile, email, offline_access, User.Read"
+ location: "Entra admin center -> App registrations -> the bm app -> Authentication and API permissions"
+ - service: google-facebook-github
+ why: "Social sign-in on the demo instance (live use only)"
+ env_vars:
+ - name: GOOGLE_CLIENT_ID
+ source: "Google Cloud console -> APIs & Services -> Credentials (OAuth client, web)"
+ - name: GOOGLE_CLIENT_SECRET
+ source: "same OAuth client"
+estimate:
+ tokens: 210000
+ raw_tokens: 210000
+ tasks: 4
+ confidence: low
+must_haves:
+ truths:
+ - "D-21: sm-demo-app mounts golem15.sociallogin from the submodule plugins/golem15/sociallogin (git@git.golem15.com:golem15/sm-sociallogin-plugin.git), boots it without a database next to every other plugin, and routes /oauth/{provider}, /oauth/{provider}/callback and /_user/api/v1/oauth-complete; with Google credentials in the environment GET /oauth/google redirects to accounts.google.com with prompt=select_account and a summer_oauth_google cookie on Path=/oauth."
+ - "Pitfall 9 in a real application: in sm-demo-app, which also mounts golem15.oauthserver, the router builds without a conflict, /oauth/jwks, /oauth/authorize and /oauth/userinfo stay routed to the OIDC provider, and GET /oauth/token or GET /oauth/nope answer 404 from sociallogin."
+ - "D-21, D-04, D-11: sm-bm-app mounts golem15.sociallogin for Microsoft 365 with the tenant configurable (default common); /oauth is a reserved prefix of the jz.bm SPA fallback; with Microsoft credentials GET /oauth/microsoft redirects to login.microsoftonline.com//oauth2/v2.0/authorize with offline_access."
+ - "D-05, D-06, D-17: the bm frontend starts sign-in with a top-level navigation to /oauth/microsoft?mode=spa&action=login&frontend_callback=/auth/oauth/callback&return_to=; its /auth/oauth/callback view exchanges ?code= through oauth-complete exactly once, removes the code from the address bar, stores the session as the password login does, and shows ?error= messages and routes ?pending_registration= to a consent view that posts oauth-register-complete."
+ - "D-23, D-14: a signed-in bm user attaches Microsoft from the profile view through POST /_user/api/v1/oauth-link-ticket and /oauth/microsoft?action=link&link_ticket=…; the JWT never appears in a URL."
+ - "D-24: fonoteka.go builds and its suites pass at the bumped sm-user-plugin commit with the relaxed unlink rule; any fonoteka test or parity case that asserted the old 409 for a user with a password is updated."
+ - "D-21 commit order: each plugin repository (sm-bm-plugin) is committed and pushed before the application commit that bumps its pointer; sm-user-plugin pointer bumps are their own commits; app pushes happen only under plan 02's Task 5 reply and only when every unpushed commit is this phase's."
+ artifacts:
+ - path: "../sm-demo-app/sociallogin_test.go"
+ provides: "TestSocialLoginRedirect, TestSocialLoginCoexistsWithOAuthServer"
+ contains: "TestSocialLoginRedirect"
+ - path: "../sm-bm-app/sociallogin_test.go"
+ provides: "TestSocialLoginMounted (Microsoft redirect, provider list)"
+ contains: "TestSocialLoginMounted"
+ - path: "../sm-bm-app/frontend/src/lib/oauth.ts"
+ provides: "oauthStartUrl, parseOAuthCallback"
+ contains: "frontend_callback"
+ - path: "../sm-bm-app/frontend/src/views/OAuthCallbackView.vue"
+ provides: "code exchange, pending and error handling"
+ contains: "oauthComplete"
+ - path: "../sm-bm-app/plugins/jz/bm/static.go"
+ provides: "/oauth reserved from the SPA fallback"
+ contains: "\"/oauth\""
+ key_links:
+ - from: "../sm-bm-app/frontend/src/views/OAuthCallbackView.vue"
+ to: "/_user/api/v1/oauth-complete"
+ via: "userApi.oauthComplete then auth store setSession"
+ pattern: "oauth-complete"
+ - from: "../sm-bm-app/frontend/src/views/ProfileView.vue"
+ to: "/_user/api/v1/oauth-link-ticket"
+ via: "userApi.oauthLinkTicket then a top-level navigation with link_ticket"
+ pattern: "link_ticket|oauthLinkTicket"
+ - from: "../sm-demo-app/summer.yaml"
+ to: "../sm-demo-app/plugins.gen.go"
+ via: "summer build regenerates the plugin imports and PluginIDs"
+ pattern: "sm-sociallogin-plugin"
+ prohibitions:
+ - statement: "No client id, client secret, tenant ID of a real registration or JWT is committed to any repository; credentials live only in the environment (.env, never committed) or the admin settings."
+ status: unresolved
+ verification: judgment
+ - statement: "The bm frontend never puts the JWT in a URL and never logs the one-time code; the callback view replaces the route so the code leaves the history."
+ status: unresolved
+ verification: judgment
+ - statement: "Nothing is deployed; pushes follow plan 02's Task 5 reply, plugin before pointer, never force, and never with another session's unpushed commits."
+ status: unresolved
+ verification: judgment
+ - statement: "fonoteka.go does not mount golem15.sociallogin, and no other fonoteka.go path than the plugins/golem15/user pointer is staged by this plan (other sessions' pointer changes for feedback and golem stay untouched)."
+ status: unresolved
+ verification: judgment
+ - statement: "No new npm dependency in the bm frontend and no new Go module in the applications beyond what the plugin's go.mod brings."
+ status: unresolved
+ verification: judgment
+---
+
+## Phase Goal
+
+ROADMAP Goal (not in user-story format; no story invented, see plan 01): social sign-in matching the PHP user plugin's contract, delivered by the new `sm-sociallogin-plugin` (CONTEXT D-01). ROADMAP SC-3 asks that the applications consuming the user plugin still build and pass their tests.
+
+This plan's slice (plan 3 of 4, wave 3, D-25 "Adoption"): the plugin reaches applications. The tracer mounts it in sm-demo-app and proves a Google redirect next to the OIDC provider. Then sm-bm-app mounts it for Microsoft 365, the bm frontend gets the sign-in button, the callback and consent views and the profile link through the link ticket, and fonoteka.go is proven green at the bumped sm-user-plugin commit (D-24).
+
+## Spec-less probe fallback
+
+spec-less probe fallback skipped: phase has no requirement IDs to probe (visible skip). `must_haves` derive from SC-1..SC-4 and D-01..D-25; prohibitions recalled from CONTEXT and RESEARCH are listed without descriptors.
+
+## Decisions taken in this plan (recorded assumptions and Claude's discretion)
+
+- The bm frontend keeps the plugin's default callback path `/auth/oauth/callback` (D-17 default, the path Horoskopia uses), so bm needs no `callback_path` config; the consent view is `/auth/oauth/rejestracja`. Both routes are public and not guest-only, because the link flow returns a signed-in user to the callback.
+- bm is same-origin (the SPA is embedded in the binary), so `frontend_url` stays empty (falls back to app.url, or to the relative path when app.url is empty) and `allowed_hosts` stays empty. The production redirect URI is set explicitly through the env template (`SUMMER_GOLEM15__SOCIALLOGIN__PROVIDERS__MICROSOFT__REDIRECT`, D-18 override), because bm's committed config has no app.url.
+- bm's tenant stays `common` in committed config (D-04 default) and is set per deployment through `MICROSOFT_TENANT`. D-11 is unchanged: Microsoft is never verified, so an existing bm student whose email matches is sent to the pending screen and told to sign in with the password and link Microsoft from the profile (the D-23 link ticket makes that possible).
+- bm does not mount the user plugin's identity list and unlink handlers (D-14 host-mounted, not required by D-21); the profile view only offers "attach Microsoft". Showing linked identities is not part of this phase.
+- The demo instance mounts the plugin with all four providers off until credentials are set (D-21 needs only mounting and config); the demo frontend (vue-demo-app) is not changed.
+- D-24: fonoteka's committed `plugins/golem15/user` pointer is bumped to the pushed 19.5 commit in its own commit; fonoteka's in-flight `feedback` and `golem` pointer changes belong to other sessions and are never staged.
+
+## Artifacts this phase produces (this plan)
+
+- sm-demo-app: submodule `plugins/golem15/sociallogin`, go.work `use ./plugins/golem15/sociallogin`, summer.yaml entry `golem15.sociallogin`, regenerated plugins.gen.go, `wantPlugins` with `golem15.sociallogin`, tests `TestSocialLoginRedirect` and `TestSocialLoginCoexistsWithOAuthServer` (sociallogin_test.go), env template lines, README plugin row; user pointer bump.
+- sm-bm-plugin (`plugins/jz/bm`): `reservedPrefixes` gains `/oauth`; `TestStaticReservedPaths` gains /oauth cases; rebuilt `public/`.
+- sm-bm-app: submodule `plugins/golem15/sociallogin`, go.mod require and replace, go.work use, summer.yaml entry, plugins.gen.go, `config/golem15.sociallogin.yaml`, env template lines, DEPLOY.md section "Microsoft 365 sign-in", README row, `TestSocialLoginMounted` (sociallogin_test.go), vite proxy `/oauth`; user and sm-bm-plugin pointer bumps.
+- bm frontend: `oauthStartUrl(provider, opts)` and `parseOAuthCallback(query)` (src/lib/oauth.ts) with tests in src/lib/oauth.test.ts; `userApi.oauthProviders`, `userApi.oauthComplete`, `userApi.oauthRegisterComplete`, `userApi.oauthLinkTicket` (src/api/user.ts); auth store actions `completeOAuth` and `completeOAuthRegistration` (src/stores/auth.ts); routes `oauth-callback` (`/auth/oauth/callback`) and `oauth-register` (`/auth/oauth/rejestracja`); views `OAuthCallbackView.vue`, `OAuthRegisterView.vue`; a Microsoft button on LoginView.vue and a link button on ProfileView.vue.
+- fonoteka.go: `plugins/golem15/user` pointer bump.
+
+
+Mount sm-sociallogin-plugin in sm-demo-app and sm-bm-app (Microsoft 365 with the frontend callback, consent and link wiring), keep fonoteka.go green at the bumped sm-user-plugin commit, and push under the plan 02 reply in D-21 order.
+
+Purpose: D-21, D-23 (bm uses the link ticket), D-24; SC-1 (the PHP paths in real applications), SC-3 (consuming applications build and pass).
+Output: commits in sm-bm-plugin, sm-demo-app, sm-bm-app and fonoteka.go, pushed per the plan 02 Task 5 reply.
+
+
+
+@~/.claude/msd-core/workflows/execute-plan.md
+@~/.claude/msd-core/templates/summary.md
+
+
+
+@.planning/phases/19.5-social-login-in-sm-user-plugin/19.5-CONTEXT.md
+@.planning/phases/19.5-social-login-in-sm-user-plugin/19.5-PATTERNS.md
+@.planning/phases/19.5-social-login-in-sm-user-plugin/19.5-02-SUMMARY.md
+
+Repos and concurrency: ../sm-demo-app, ../sm-bm-app (with its submodule ../sm-bm-app/plugins/jz/bm = git.golem15.com:jakub/sm-bm-plugin, its own repository), ../fonoteka.go. sm-bm-app has other sessions' uncommitted files (.planning/, .entroly/, .gsd/, .msd/ and others): never stage them; stage only this plan's paths. fonoteka.go has other sessions' submodule pointer changes for plugins/golem15/feedback and plugins/golem15/golem: never stage them. Never stage files inside a submodule from the application repository; commit inside the submodule, push it, then commit the pointer in the application. No co-author tags; one logical change per commit. Before each push: `git fetch origin` and `git log --oneline origin/master..master` must list only this plan's commits (and, for the plugins, this phase's), else stop and report. Never force-push, never deploy.
+
+Precondition for the whole plan: 19.5-02-SUMMARY.md records the Task 5 reply "release vX.Y.Z" or "push-only" and that sm-sociallogin-plugin's origin/master exists. On "hold" this plan does not start.
+
+Docker-backed tests skip under -short; run them on a Docker host (through sudo -u golem if the socket is denied) and record NOT RUN when no daemon answers.
+
+
+- sm-demo-app: summer.yaml (oauthserver entry :32-33 as the model; insert golem15.sociallogin after golem15.oauthserver and before golem15.demo), go.work (use list; replace of sm-user-plugin to ./plugins/golem15/user), .gitmodules (oauthserver block with branch master), boot_test.go (`wantPlugins` :17-22 in summer.yaml order, `TestBootAllPlugins`, `loadCommittedConfig`, `routed`), plugins.gen.go (generated by `summer build`; `PluginIDs`), deploy/env.example, README.md (plugin list), commit e64dd0c (how the oauthserver mount was committed: .gitmodules, README, boot_test, go.work, plugins.gen.go, submodule, user pointer, summer.yaml).
+- sm-bm-app: summer.yaml (golem15.user :4-5), go.mod (require git.golem15.com/golem15/sm-user-plugin v0.0.0 with replace => ./plugins/golem15/user; same pattern for the new plugin), go.work, .gitmodules, plugins.gen.go, realtime_test.go (`realtimeApp` boot harness without a database: temp config dir, environ, party.Activate, surf.BuildRouter), config/ (golem15.user.yaml style), deploy/env.example, DEPLOY.md, README.md (frontend build and `summer build` steps :22, :41).
+- sm-bm-plugin: static.go (`reservedPrefixes` :42), static_test.go (`TestStaticReservedPaths` :112), public/ (vite outDir ../plugins/jz/bm/public, emptyOutDir).
+- bm frontend: vite.config.ts (proxy map), src/router.ts (route style :10-11, guard: public and guest meta), src/api/user.ts (`client.request(url, {method, body, auth})`, base `/_user/api/v1`), src/stores/auth.ts (`setSession(token, user, remember)` :28, `login` :66), src/lib/forms.ts (`formError`, `safeRedirect`), src/views/LoginView.vue (form :38-54, `btn primary block`), src/views/ProfileView.vue, src/stores/toast.ts, tests `src/**/*.test.ts` (vitest, node environment).
+- Plugin contract (plan 02 SUMMARY has final names): GET /oauth/{p}?mode=spa&action=login|register|link&frontend_callback=…&return_to=…[&link_ticket=…][&terms_accepted=1&privacy_accepted=1&marketing_consent=1]; callback redirects `{callback}?code=`, `?pending_registration=[&reason=]&provider=
[&email=]`, `?error=`; GET /_user/api/v1/oauth-complete?code= → {token,user,action,return_to} / 422 / 410; POST /_user/api/v1/oauth-register-complete {pending_code, terms_accepted, privacy_accepted, marketing_consent} → 200 / 410 / 422 {error, errors?}; POST /_user/api/v1/oauth-link-ticket (bearer) → {link_ticket, expires_in}; GET /_user/api/v1/oauth-providers → {success, providers:[{name,label}]}; config keys and env names in 19.5-02-PLAN.md Decisions.
+- fonoteka.go: plugins/golem15/fonoteka/routes.go :240-246 (host-mounted identity routes), parity fixtures for oauth-identities (DELETE records 404), go.work.
+
+
+
+
+
+
+ Task 1: Tracer: the demo instance mounts sociallogin from its repository, boots it next to the OIDC provider, and redirects a visitor to Google with a sealed state cookie
+ 19.5-02-SUMMARY.md records the Task 5 reply "release vX.Y.Z" or "push-only" and `git ls-remote --exit-code git@git.golem15.com:golem15/sm-sociallogin-plugin.git refs/heads/master` exits 0.
+ D-01: the demo's summer.yaml, submodule path and env names now reference the plugin; renaming it later touches every mounting application.
+ ../sm-demo-app/.gitmodules, ../sm-demo-app/plugins/golem15/sociallogin, ../sm-demo-app/plugins/golem15/user, ../sm-demo-app/go.work, ../sm-demo-app/summer.yaml, ../sm-demo-app/plugins.gen.go, ../sm-demo-app/boot_test.go, ../sm-demo-app/sociallogin_test.go, ../sm-demo-app/deploy/env.example, ../sm-demo-app/README.md
+ 19.5-02-SUMMARY.md (Task 5 reply, tags, final config keys), ../sm-demo-app/summer.yaml, ../sm-demo-app/go.work, ../sm-demo-app/.gitmodules, ../sm-demo-app/boot_test.go, ../sm-demo-app/plugins.gen.go, ../sm-demo-app/deploy/env.example, ../sm-demo-app/README.md, `git -C ../sm-demo-app show --stat e64dd0c` (the oauthserver mount commit to imitate), ../sm-sociallogin-plugin/README.md, ../sm-sociallogin-plugin/config/config.yaml
+ Per D-21, in two commits in ../sm-demo-app.
+
+(1) User plugin pointer (its own commit): in plugins/golem15/user `git fetch origin` and check out the pushed 19.5 head of sm-user-plugin (tag v0.1.2 on release, origin/master on push-only); `go -C ../sm-demo-app build ./...` and `go -C ../sm-demo-app test -count=1 -short .` green; commit only the pointer: `chore: bump golem15.user for social sign-in hooks`.
+
+(2) Mount (one commit, as e64dd0c did for oauthserver): `git -C ../sm-demo-app submodule add -b master git@git.golem15.com:golem15/sm-sociallogin-plugin.git plugins/golem15/sociallogin` and check out tag v0.1.0 (release) or origin/master (push-only); go.work `use ./plugins/golem15/sociallogin`; summer.yaml entry `- id: golem15.sociallogin` / `module: git.golem15.com/golem15/sm-sociallogin-plugin` after golem15.oauthserver; run `summer build` (installed from ../summercms.go with `go install ./cmd/summer`) to regenerate plugins.gen.go (and nothing else; if it rewrites other files, keep only the plugins.gen.go change and report); boot_test.go `wantPlugins` gains "golem15.sociallogin" in summer.yaml order and the routed prefixes gain "/oauth/" and "/_user/api/v1/oauth-complete"; deploy/env.example gains a commented block for the four providers using the plugin's env names (`SUMMER_GOLEM15__SOCIALLOGIN__PROVIDERS__GOOGLE__CLIENT_ID=` and so on, plus a note that the legacy GOOGLE_CLIENT_ID style names also work) with empty values; README lists golem15.sociallogin with one line (off until credentials are set). New sociallogin_test.go: `TestSocialLoginRedirect` boots the committed config like TestBootAllPlugins with `golem15.sociallogin.providers.google.client_id` and `client_secret` set to test-only strings, builds the router, serves it through httptest and asserts GET /oauth/google?mode=spa&action=login&frontend_callback=/auth/oauth/callback answers 302 with a Location on accounts.google.com containing prompt=select_account and a state parameter, and a Set-Cookie summer_oauth_google with Path=/oauth and HttpOnly; GET /_user/api/v1/oauth-providers lists google (no database needed: credentials come from config). `TestSocialLoginCoexistsWithOAuthServer` builds the same router and asserts `routed` finds /oauth/jwks, /oauth/authorize and /oauth/userinfo (oauthserver) and /oauth/{provider} (sociallogin), and that GET /oauth/token and GET /oauth/nope answer 404 (RESEARCH Pitfall 9). Commit: `feat: mount golem15.sociallogin`. Push sm-demo-app per the plan 02 reply after the pre-push check.
+
+ go -C ../sm-demo-app build ./... && go -C ../sm-demo-app vet . && go -C ../sm-demo-app test -count=1 -short . && go -C ../sm-demo-app test -count=1 -run '^(TestBootAllPlugins|TestSocialLoginRedirect|TestSocialLoginCoexistsWithOAuthServer)$' -v . 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 3
+ Non-zero exit (the demo no longer builds, vet or a root test is red) or the count of top-level "--- PASS: Test" lines is not 3 (a boot, redirect or coexistence test failed or was not found).
+
+
+ - `grep -c 'git.golem15.com/golem15/sm-sociallogin-plugin' ../sm-demo-app/summer.yaml ../sm-demo-app/plugins.gen.go | awk -F: '{s+=$2} END {print s}'` prints at least 2.
+ - `grep -c 'sm-sociallogin-plugin.git' ../sm-demo-app/.gitmodules` prints at least 1 and `grep -c './plugins/golem15/sociallogin' ../sm-demo-app/go.work` prints at least 1.
+ - `grep -c '"golem15.sociallogin"' ../sm-demo-app/boot_test.go` prints at least 1.
+ - `S=$(git -C ../sm-demo-app/plugins/golem15/sociallogin status --porcelain) && test -z "$S"` exits 0 (no edits inside the submodule).
+ - `grep -cE 'CLIENT_SECRET=[^ ]+' ../sm-demo-app/deploy/env.example` prints 0 (no secret value committed).
+
+ The demo instance runs social sign-in next to its OIDC provider: a visitor clicking a configured provider is sent to it with a sealed state cookie, and nothing of the OIDC provider is shadowed.
+
+
+
+ Task 2: sm-bm-app signs students in with Microsoft 365 on the backend: /oauth reserved from the SPA fallback, the plugin mounted with tenant and redirect configuration, deploy notes for the Entra registration
+ Task 1 is committed; sm-user-plugin and sm-sociallogin-plugin are on origin (19.5-02-SUMMARY.md).
+ ../sm-bm-app/plugins/jz/bm/static.go, ../sm-bm-app/plugins/jz/bm/static_test.go, ../sm-bm-app/.gitmodules, ../sm-bm-app/plugins/golem15/sociallogin, ../sm-bm-app/plugins/golem15/user, ../sm-bm-app/go.mod, ../sm-bm-app/go.sum, ../sm-bm-app/go.work, ../sm-bm-app/summer.yaml, ../sm-bm-app/plugins.gen.go, ../sm-bm-app/sociallogin_test.go, ../sm-bm-app/config/golem15.sociallogin.yaml, ../sm-bm-app/deploy/env.example, ../sm-bm-app/DEPLOY.md, ../sm-bm-app/README.md, ../sm-bm-app/frontend/vite.config.ts
+ ../sm-bm-app/plugins/jz/bm/static.go (:39-45, :98-131), ../sm-bm-app/plugins/jz/bm/static_test.go (TestStaticReservedPaths :112), ../sm-bm-app/summer.yaml, ../sm-bm-app/go.mod (:5-12, :90-94), ../sm-bm-app/go.work, ../sm-bm-app/.gitmodules, ../sm-bm-app/realtime_test.go (realtimeApp harness), ../sm-bm-app/config/golem15.user.yaml, ../sm-bm-app/deploy/env.example, ../sm-bm-app/DEPLOY.md, ../sm-bm-app/README.md, ../sm-bm-app/frontend/vite.config.ts (proxy), ../sm-sociallogin-plugin/config/config.yaml, 19.5-RESEARCH.md (Q2, Q7)
+ Per D-04, D-11, D-18, D-21.
+
+sm-bm-plugin first (in ../sm-bm-app/plugins/jz/bm, its own repository): `reservedPrefixes` gains "/oauth" (comment: the social sign-in routes of golem15.sociallogin); `TestStaticReservedPaths` gains cases that /oauth/microsoft and /oauth/microsoft/callback are not answered with index.html while /oauthy (a name that merely starts like the prefix) still is, following the existing cases; `go test ./...` there green; commit `fix: reserve /oauth for social sign-in`. It is pushed in Task 3 together with the rebuilt public/, before the pointer bump.
+
+sm-bm-app (commits in this order, each alone): (1) `chore: bump golem15.user for social sign-in hooks`: the plugins/golem15/user pointer from 24b16b1 to the pushed 19.5 commit (v0.1.2 on release, origin/master on push-only); bm builds and its short tests pass. (2) `feat: mount golem15.sociallogin for Microsoft 365`: submodule `plugins/golem15/sociallogin` (git@git.golem15.com:golem15/sm-sociallogin-plugin.git, branch master, at v0.1.0 or origin/master); go.mod `require git.golem15.com/golem15/sm-sociallogin-plugin v0.0.0` plus `replace git.golem15.com/golem15/sm-sociallogin-plugin => ./plugins/golem15/sociallogin` (the golem15.user pattern) and `go mod tidy` for goth's modules; go.work `use ./plugins/golem15/sociallogin`; summer.yaml `- id: golem15.sociallogin` after golem15.user; `summer build` regenerates plugins.gen.go; config/golem15.sociallogin.yaml with a comment header and `providers: microsoft: tenant: common` only (credentials and redirect come from the environment; callback_path, frontend_url and allowed_hosts stay at their defaults because the SPA is same-origin); deploy/env.example gains MICROSOFT_CLIENT_ID=, MICROSOFT_CLIENT_SECRET=, MICROSOFT_TENANT= (empty, with a comment: common, organizations or the university's tenant ID) and `SUMMER_GOLEM15__SOCIALLOGIN__PROVIDERS__MICROSOFT__REDIRECT=https://bm.jakubzych.com/oauth/microsoft/callback` (D-18 override; bm's committed config has no app.url); DEPLOY.md gains a section "Microsoft 365 sign-in" (Entra app registration: web platform redirect URI above, delegated permissions openid, profile, email, offline_access, User.Read, a client secret with its expiry noted; the three env values; that Microsoft never auto-links by email (D-11) so existing students attach Microsoft from the profile; that one-time codes live in the cache, so a multi-instance setup needs the Redis cache driver); README lists the plugin; frontend/vite.config.ts proxy gains '/oauth': backend. New root test sociallogin_test.go `TestSocialLoginMounted` (no database, runs under -short, harness like realtimeApp) boots the PluginIDs with MICROSOFT_CLIENT_ID and MICROSOFT_CLIENT_SECRET set to test-only strings and MICROSOFT_TENANT=organizations: GET /oauth/microsoft?mode=spa&action=login&frontend_callback=/auth/oauth/callback answers 302 to login.microsoftonline.com with path /organizations/oauth2/v2.0/authorize and a scope containing offline_access and a summer_oauth_microsoft cookie on Path=/oauth; GET /_user/api/v1/oauth-providers lists {name microsoft, label Microsoft}; GET /oauth/google answers 302 to /auth/oauth/callback?error= (registered but not configured). The sm-bm-plugin pointer is not committed here (Task 3 bumps it once, after the push).
+
+ go -C ../sm-bm-app/plugins/jz/bm test -count=1 -short -run '^TestStaticReservedPaths$' -v . 2>&1 | grep -q -- '^--- PASS: TestStaticReservedPaths' && go -C ../sm-bm-app build ./... && go -C ../sm-bm-app vet . && go -C ../sm-bm-app test -count=1 -short . && go -C ../sm-bm-app test -count=1 -short -run '^TestSocialLoginMounted$' -v . 2>&1 | grep -q -- '^--- PASS: TestSocialLoginMounted'
+ Non-zero exit: the reserved-path test, the bm build, vet or a root test is red, or either "--- PASS: TestStaticReservedPaths" or "--- PASS: TestSocialLoginMounted" is missing (the SPA fallback still swallows /oauth, or the Microsoft redirect, tenant or provider list is wrong).
+
+
+ - `grep -c '"/oauth"' ../sm-bm-app/plugins/jz/bm/static.go` prints at least 1.
+ - `grep -c 'sm-sociallogin-plugin' ../sm-bm-app/go.mod ../sm-bm-app/summer.yaml ../sm-bm-app/plugins.gen.go | awk -F: '{s+=$2} END {print s}'` prints at least 3.
+ - `grep -c 'tenant: common' ../sm-bm-app/config/golem15.sociallogin.yaml` prints at least 1 and `grep -cE 'client_secret: *[^ ]+' ../sm-bm-app/config/golem15.sociallogin.yaml` prints 0.
+ - `grep -c 'Microsoft 365 sign-in' ../sm-bm-app/DEPLOY.md` prints at least 1 and `grep -c "'/oauth'" ../sm-bm-app/frontend/vite.config.ts` prints at least 1.
+ - `S=$(git -C ../sm-bm-app diff --cached --name-only) && test -z "$S"` exits 0 after the commits (nothing left staged, no other session's file swept in).
+
+ The bm binary answers /oauth/microsoft with a correct Entra redirect, the SPA no longer swallows /oauth paths, and the operator knows how to register the Entra app.
+
+
+
+ Task 3: A bm student signs in with Microsoft 365 from the login page, finishes a first sign-in on the consent screen, and attaches Microsoft from the profile with a link ticket
+ ../sm-bm-app/frontend/src/lib/oauth.ts, ../sm-bm-app/frontend/src/lib/oauth.test.ts, ../sm-bm-app/frontend/src/api/user.ts, ../sm-bm-app/frontend/src/stores/auth.ts, ../sm-bm-app/frontend/src/router.ts, ../sm-bm-app/frontend/src/views/OAuthCallbackView.vue, ../sm-bm-app/frontend/src/views/OAuthRegisterView.vue, ../sm-bm-app/frontend/src/views/LoginView.vue, ../sm-bm-app/frontend/src/views/ProfileView.vue, ../sm-bm-app/plugins/jz/bm/public, ../sm-bm-app/plugins/jz/bm
+ ../sm-bm-app/frontend/src/api/user.ts, ../sm-bm-app/frontend/src/api/client.ts, ../sm-bm-app/frontend/src/stores/auth.ts, ../sm-bm-app/frontend/src/router.ts, ../sm-bm-app/frontend/src/lib/forms.ts, ../sm-bm-app/frontend/src/views/LoginView.vue, ../sm-bm-app/frontend/src/views/ProfileView.vue, ../sm-bm-app/frontend/src/stores/toast.ts, ../sm-bm-app/frontend/src/lib/format.test.ts (vitest style), /media/nvme/dev/golem15/horoskopia.eu/vue-horoskopia-app/app/stores/auth.ts (:432-551, the reference client), /media/nvme/dev/golem15/horoskopia.eu/vue-horoskopia-app/app/pages/auth/oauth/callback.vue and register.vue, ../sm-sociallogin-plugin/README.md (Routes)
+ Per D-05, D-06, D-09 (default body mode), D-14, D-17, D-23; Polish UI copy like the rest of bm; no new npm dependency.
+
+src/lib/oauth.ts: `oauthStartUrl(provider: string, opts: { action: 'login' | 'register' | 'link'; returnTo?: string; linkTicket?: string; consent?: { terms: boolean; privacy: boolean; marketing: boolean } }): string` returning `/oauth/?mode=spa&action=&frontend_callback=&return_to=` plus `&link_ticket=` for link and `terms_accepted=1|0&privacy_accepted=1|0` and `marketing_consent=1` only when true for register (Horoskopia :447-452); `parseOAuthCallback(query: Record)` returning one of `{ kind: 'code', code }`, `{ kind: 'pending', pending, reason?, provider, email? }`, `{ kind: 'error', message }`, `{ kind: 'none' }` from the query keys code, pending_registration, reason, provider, email, error (first string value of an array). src/lib/oauth.test.ts covers the URL encoding of each action, a return_to with query characters, and each callback kind including an array-valued query.
+
+src/api/user.ts: `oauthProviders()` GET `${base}/oauth-providers` (auth false) typed `{ success: boolean; providers: { name: string; label: string }[] }`; `oauthComplete(code)` GET `${base}/oauth-complete?code=` (auth false) typed `{ token: string; user: User; action: string; return_to: string }`; `oauthRegisterComplete(b: { pending_code: string; terms_accepted: boolean; privacy_accepted: boolean; marketing_consent: boolean })` POST (auth false), same type; `oauthLinkTicket()` POST `${base}/oauth-link-ticket` (auth true) typed `{ link_ticket: string; expires_in: number }`.
+
+src/stores/auth.ts: `completeOAuth(code: string)` calls oauthComplete, `setSession(res.token, res.user, true)` and returns `{ action, returnTo }`; `completeOAuthRegistration(b)` the same for oauthRegisterComplete; both exported from the store.
+
+src/router.ts: `{ path: '/auth/oauth/callback', name: 'oauth-callback', component: () => import('@/views/OAuthCallbackView.vue'), meta: { public: true, title: 'Logowanie' } }` and `{ path: '/auth/oauth/rejestracja', name: 'oauth-register', component: () => import('@/views/OAuthRegisterView.vue'), meta: { public: true, title: 'Rejestracja' } }` (public, not guest, so the link flow can return a signed-in user), placed before the catch-all.
+
+OAuthCallbackView.vue: on mount parse the query, then immediately `router.replace` to the same path without query (the code leaves the history); code → completeOAuth (one call, guarded against a second mount); action link → toast "Połączono konto Microsoft" and go to /profil; otherwise go to `safeRedirect(returnTo)`; 410 or 422 → message "Link logowania wygasł lub został już użyty." with a link to /logowanie; pending → navigate to oauth-register with pending, reason, provider and email in the query (state kept out of localStorage); error → show the message text (escaped by Vue) with a link to /logowanie.
+
+OAuthRegisterView.vue: shows the email when present and, for reason unverified_match, the explanation that an account with this email exists, so the student should sign in with the password and attach Microsoft from the profile (with a link to /logowanie) instead of registering; checkboxes for terms and privacy (required) and marketing (optional); submit calls completeOAuthRegistration with pending_code; 422 with errors keys shows them per field (formError) and keeps the form; 422 without errors shows `error`; 410 shows "Sesja rejestracji wygasła. Zaloguj się ponownie." with a link to /logowanie; success goes to `safeRedirect(returnTo)`.
+
+LoginView.vue: on mount call oauthProviders (failure hides the button silently); when the list contains microsoft, show a second block button "Zaloguj przez Microsoft 365" below the form that does `window.location.assign(oauthStartUrl('microsoft', { action: 'login', returnTo: safeRedirect(route.query.redirect) }))`. ProfileView.vue: a section "Konto Microsoft 365" (shown when oauthProviders lists microsoft) with a button "Połącz konto Microsoft" that calls oauthLinkTicket and then `window.location.assign(oauthStartUrl('microsoft', { action: 'link', linkTicket, returnTo: '/profil' }))`; errors through the toast store.
+
+Build and commit: `npm --prefix ../sm-bm-app/frontend run test`, `run typecheck`, `run build` (writes plugins/jz/bm/public). In sm-bm-plugin commit the rebuilt public/ alone (`build: frontend with Microsoft 365 sign-in`), then push sm-bm-plugin (both commits: the Task 2 static.go fix and this build) after the pre-push check. In sm-bm-app commit the frontend sources (`feat: Microsoft 365 sign-in in the frontend`), then the sm-bm-plugin pointer alone (`chore: bump jz.bm for /oauth and the rebuilt frontend`). Then `go -C ../sm-bm-app test -count=1 -short ./plugins/jz/bm/...` (TestEmbeddedPublicHasIndex) and the root tests again, and push sm-bm-app per the plan 02 reply after the pre-push check.
+
+ npm --prefix ../sm-bm-app/frontend run test && npm --prefix ../sm-bm-app/frontend run typecheck && npm --prefix ../sm-bm-app/frontend run build && go -C ../sm-bm-app build ./... && go -C ../sm-bm-app/plugins/jz/bm test -count=1 -short -run '^(TestEmbeddedPublicHasIndex|TestStaticReservedPaths)$' -v . 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 2
+ Non-zero exit (a vitest case including oauth.test.ts fails, vue-tsc reports a type error, the vite build fails, or bm no longer compiles) or the count of top-level "--- PASS: Test" lines is not 2 (the embedded build has no index.html or /oauth is no longer reserved).
+
+
+ - `grep -c 'oauthStartUrl' ../sm-bm-app/frontend/src/lib/oauth.ts ../sm-bm-app/frontend/src/views/LoginView.vue ../sm-bm-app/frontend/src/views/ProfileView.vue | awk -F: '{s+=$2} END {print s}'` prints at least 3.
+ - `grep -c "/auth/oauth/callback" ../sm-bm-app/frontend/src/router.ts` prints at least 1 and `grep -c 'oauth-link-ticket' ../sm-bm-app/frontend/src/api/user.ts` prints at least 1.
+ - `grep -rnE 'token=|access_token=' ../sm-bm-app/frontend/src/lib/oauth.ts | grep -c .` prints 0 (no JWT in the start URL).
+ - `grep -c 'router.replace' ../sm-bm-app/frontend/src/views/OAuthCallbackView.vue` prints at least 1.
+ - `L=$(git -C ../sm-bm-app/plugins/jz/bm log --oneline origin/master..master) && test -z "$L"` exits 0 when the plan 02 reply allows pushes (sm-bm-plugin pushed before the pointer bump).
+ - `P=$(git -C ../sm-bm-app ls-tree HEAD plugins/jz/bm) && R=$(git -C ../sm-bm-app/plugins/jz/bm rev-parse HEAD) && printf '%s\n' "$P" | grep -q "$R"` exits 0 (the committed pointer is the pushed sm-bm-plugin head).
+
+ A bm student can sign in with Microsoft 365 from the login page, a newcomer finishes on the consent screen, an existing student with a password attaches Microsoft from the profile, and the bm binary embeds that frontend.
+
+
+
+ Task 4: fonoteka.go builds and passes at the bumped sm-user-plugin commit with the relaxed unlink rule, and the phase milestone is updated
+ ../fonoteka.go/plugins/golem15/user
+ ../fonoteka.go/plugins/golem15/fonoteka/routes.go (:240-246), ../fonoteka.go/parity/fonoteka_seed_test.go (oauth-identities extras), ../fonoteka.go/parity/fixtures/routes (oauth-identities fixtures), ../fonoteka.go/go.work, 19.5-02-SUMMARY.md (sm-user-plugin tag or pushed head), .planning/G15OFFICE.md
+ Per D-24 and D-21. The fonoteka.go workspace builds sm-user-plugin from its checkout plugins/golem15/user, which already holds plan 01's commits. Confirm the checkout is at the pushed 19.5 head (v0.1.2 on release, origin/master on push-only) with no local changes. Search fonoteka.go for any test or parity case that asserts 409 when a user with a self-set password unlinks the last identity (`grep -rn 'oauth-identities\|OAuthIdentitiesDestroy\|last_method_blocked' --include='*.go' --include='*.yaml'` outside plugins/golem15/user); the planning-time reading found only the DELETE parity fixture recording 404 (no identity), which D-13 does not change. If a case asserting the old 409 for a password user exists, update it to D-13's rule in its own commit (`test: unlink follows the PHP last-identity rule`). Run fonoteka's suites: `go -C ../fonoteka.go vet ./...`, `go -C ../fonoteka.go test -count=1 -short ./...`, and the Docker-backed run of `./plugins/golem15/user/... ./plugins/golem15/fonoteka/...` without -short.
+
+Commit only the pointer in fonoteka.go: `git -C ../fonoteka.go add plugins/golem15/user` (never feedback or golem, which carry other sessions' changes) and `chore: bump golem15.user to the social sign-in hooks`. Push fonoteka.go only when the plan 02 reply allows it and `git log --oneline origin/master..master` lists only this phase's commits; if other sessions' commits are unpushed there, leave the push to the user and record it.
+
+Post a short comment on the Phase 19.5 milestone task in g15office (`onunbz3l`, .planning/G15OFFICE.md) naming the mounted applications and pushed repositories; if the g15office tool is unavailable, record that in the SUMMARY.
+
+ go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test -count=1 -short ./... && go -C ../fonoteka.go build ./...
+ Non-zero exit: fonoteka.go no longer vets, compiles or passes its short suite at the bumped sm-user-plugin commit.
+ go -C ../fonoteka.go test -count=1 ./plugins/golem15/user/... ./plugins/golem15/fonoteka/... 2>&1 | tee /dev/stderr | grep -c '^FAIL' | grep -qx 0
+ Any "FAIL" line in the Docker-backed user and fonoteka plugin run (a parity or integration test broke under the relaxed unlink rule); when no Docker daemon answers, record NOT RUN instead of passing.
+
+
+ - `P=$(git -C ../fonoteka.go ls-tree HEAD plugins/golem15/user) && R=$(git -C ../fonoteka.go/plugins/golem15/user rev-parse HEAD) && printf '%s\n' "$P" | grep -q "$R"` exits 0 (the committed pointer is the checked-out 19.5 head).
+ - `F=$(git -C ../fonoteka.go show --name-only --format= HEAD) && test "$F" = "plugins/golem15/user"` exits 0 when the pointer commit is HEAD (only the user pointer was staged).
+ - `grep -rn 'golem15.sociallogin' ../fonoteka.go/summer.yaml | grep -c .` prints 0 (fonoteka does not mount the plugin).
+
+ fonoteka.go builds and passes at the new sm-user-plugin commit, with the relaxed unlink rule and no social sign-in mounted, and its pointer records that commit.
+
+
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+|----------|-------------|
+| browser to bm SPA callback view | the code, pending code and error text arrive in the query string from a redirect |
+| SPA to /_user/api/v1/oauth-* | the bm frontend exchanges codes and requests link tickets with its bearer token |
+| operator to deploy configuration | Entra and provider credentials entered on the server |
+| this session to shared repositories | pushes to origin of repositories other sessions also use |
+
+## STRIDE Threat Register
+
+| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
+|-----------|----------|-----------|----------|-------------|-----------------|
+| T-19.5-24 | Tampering | jz.bm static SPA fallback | medium | mitigate | /oauth added to reservedPrefixes so provider callbacks and unknown /oauth paths are never answered with index.html; TestStaticReservedPaths |
+| T-19.5-25 | Information disclosure | committed configuration and env templates | high | mitigate | credentials only in .env (never committed) or the encrypted admin settings; acceptance criteria grep the templates and config for secret values |
+| T-19.5-26 | Information disclosure | OAuthCallbackView (code in history and logs) | medium | mitigate | the view replaces the route before exchanging the code, exchanges it once, logs nothing; the server consumes it with Pull, so a code left in history is already dead |
+| T-19.5-27 | Spoofing | Microsoft sign-in on bm (nOAuth, tenant misconfiguration) | high | mitigate | D-11 keeps Microsoft unverified, so no auto-link by email whatever the tenant; existing students link through the authenticated link ticket; DEPLOY.md documents tenant choices |
+| T-19.5-28 | Information disclosure | link ticket in the bm profile flow | medium | mitigate | the ticket comes from an authenticated POST, lives 60 s, is single use and is the only thing in the URL; the JWT stays in storage and the Authorization header |
+| T-19.5-29 | Tampering | pushes of shared repositories | medium | mitigate | push only under plan 02's reply, plugin before pointer, after fetch and a check that every unpushed commit is this phase's; never force; never stage other sessions' files or pointers |
+| T-19.5-30 | Denial of service | fonoteka regression from the D-13 relaxation | low | mitigate | fonoteka's full short suite and the Docker-backed user and fonoteka plugin runs gate the pointer commit; the DELETE parity fixture records 404 and is unaffected |
+| T-19.5-SC | Tampering | module and npm installs | high | mitigate | applications only inherit goth through the plugin's go.mod approved in plan 01 Task 4; no npm package is added to the bm frontend; `go mod tidy` adds no module beyond goth's graph (compare go.mod diff) |
+
+
+
+- Task 1: demo build, vet, root short tests, TestBootAllPlugins, TestSocialLoginRedirect, TestSocialLoginCoexistsWithOAuthServer.
+- Task 2: TestStaticReservedPaths in sm-bm-plugin; bm build, vet, root short tests, TestSocialLoginMounted.
+- Task 3: bm vitest (oauth.test.ts and the rest), vue-tsc, vite build; TestEmbeddedPublicHasIndex; pointers equal pushed heads.
+- Task 4: fonoteka vet, build, short suite and the Docker-backed user and fonoteka plugin run.
+
+
+
+- D-21: demo and bm mount the plugin; fonoteka builds and passes at the bumped user-plugin pin; commit and push order respected.
+- SC-1: the PHP paths answer in real applications next to the OIDC provider.
+- D-23: bm attaches Microsoft through the link ticket.
+- D-24: relaxed unlink accepted in fonoteka.
+
+
+
diff --git a/.planning/phases/19.5-social-login-in-sm-user-plugin/19.5-04-PLAN.md b/.planning/phases/19.5-social-login-in-sm-user-plugin/19.5-04-PLAN.md
new file mode 100644
index 00000000..b6977f3f
--- /dev/null
+++ b/.planning/phases/19.5-social-login-in-sm-user-plugin/19.5-04-PLAN.md
@@ -0,0 +1,441 @@
+---
+phase: 19.5-social-login-in-sm-user-plugin
+plan: 04
+type: execute
+wave: 4
+depends_on: ["19.5-01", "19.5-02", "19.5-03"]
+files_modified:
+ - scripts/check-phase19.5.sh
+ - modules/cooler/pull_test.go
+ - modules/cooler/docker_test.go
+ - ../fonoteka.go/plugins/golem15/user/oauth_providers_test.go
+ - ../fonoteka.go/plugins/golem15/user/oauth_identities_rule_test.go
+ - ../fonoteka.go/plugins/golem15/user/social_hooks_test.go
+ - ../sm-sociallogin-plugin/registry_test.go
+ - ../sm-sociallogin-plugin/statecookie_test.go
+ - ../sm-sociallogin-plugin/normalize_test.go
+ - ../sm-sociallogin-plugin/codes_test.go
+ - ../sm-sociallogin-plugin/routes_test.go
+ - ../sm-sociallogin-plugin/matching_test.go
+ - ../sm-sociallogin-plugin/registration_test.go
+ - ../sm-sociallogin-plugin/link_test.go
+ - ../sm-sociallogin-plugin/complete_test.go
+ - ../sm-sociallogin-plugin/register_complete_test.go
+ - ../sm-sociallogin-plugin/settings_test.go
+ - ../sm-sociallogin-plugin/providers_goth_test.go
+ - ../sm-sociallogin-plugin/php_port_test.go
+ - ../sm-sociallogin-plugin/php_method_map_test.go
+ - ../sm-sociallogin-plugin/testdata/PHP_METHOD_MAP.md
+ - ../sm-sociallogin-plugin/testdata/php_methods.txt
+ - ../sm-sociallogin-plugin/README.md
+ - .planning/phases/19.5-social-login-in-sm-user-plugin/19.5-SECURITY-REVIEW.md
+ - .planning/phases/19.5-social-login-in-sm-user-plugin/19.5-VALIDATION.md
+ - .planning/todos/pending/oauth-password-bootstrap-change-password.md
+ - ../sm-sociallogin-plugin/go.mod
+ - ../sm-sociallogin-plugin/go.sum
+ - ../sm-demo-app/plugins/golem15/sociallogin
+ - ../sm-demo-app/plugins/golem15/user
+ - ../sm-bm-app/plugins/golem15/sociallogin
+ - ../sm-bm-app/plugins/golem15/user
+ - ../sm-bm-app/go.mod
+ - ../sm-bm-app/go.sum
+ - ../fonoteka.go/plugins/golem15/user
+ - .planning/todos/pending/sociallogin-post-release-fixes.md
+autonomous: false
+requirements: [SC-4, D-03, D-04, D-06, D-07, D-08, D-09, D-10, D-11, D-12, D-13, D-14, D-15, D-16, D-17, D-19, D-20, D-21, D-23, D-25]
+estimate:
+ tokens: 320000
+ raw_tokens: 320000
+ tasks: 7
+ confidence: low
+must_haves:
+ truths:
+ - "SC-4, D-25: the phase's new code has unit tests delivered in this, the last plan: cooler.Cache.Pull on memory, miniredis and real Redis; the sm-user-plugin additions; and every decision of sm-sociallogin-plugin in 19.5-VALIDATION.md's decision-to-test map, on Postgres where a database is involved."
+ - "D-08: N concurrent Pulls of one key yield exactly one found=true on the memory driver, on miniredis and on redis:7-alpine; an expired key is never returned; Pull touches only the application's prefix."
+ - "D-13: the unlink rule table (self-set password with one identity 204; password-less with one 409 and the unchanged body; password-less with two 204 and one left; unknown provider and foreign identity 404; microsoft allowed by default; the published source's known names used when opts.Providers is empty) and the deterministic two-transaction race in both orders pass."
+ - "D-07, D-17, D-06: table tests cover the state cookie (missing, tampered, wrong AAD, other provider, expired, future iat, oversize, empty and unequal state, attributes, cleared on every callback outcome) and the callback and return_to normalisers (relative, http and https, every allowed host kind, userinfo, query and fragment stripped, bad scheme, wrong path, //host, /\\, control characters, over-long values)."
+ - "D-10, D-11, D-12, D-14, D-19, D-23: the matching table, the per-provider email-verified rules with the nOAuth case, the beta gate on both creation paths, linking through bearer, token cookie, auth_token cookie and link ticket, account_already_linked, provider tokens stored encrypted with token_expires_at and the avatar only in profile_data run end to end on Postgres through fake providers registered in the plugin's Registry."
+ - "D-09: oauth-complete answers 422 on an empty code, 410 on an unknown or used code and 200 with {token,user,action,return_to} once; oauth-register-complete keeps the code on a consent, beta or unverified-email 422, answers 410 on a bad code, and a concurrent double submit yields exactly one 200; the token_cookie option sets bouncer's cookie and drops token from both bodies."
+ - "D-20: every /oauth route carries throttle:20,1 and every /_user/api/v1/oauth-* route throttle:user-api, asserted from the router's route information."
+ - "PHP test map: every test method of SocialAuthGateTest, OAuthLinkCookieTest, OAuthPendingRegistrationTakeoverTest, OAuthPasswordBootstrapTest and ProfileOAuthLinkTest (38 methods) appears in testdata/PHP_METHOD_MAP.md with its Go test (top-level name or name/subtest, in sociallogin or sm-user-plugin) or not-applicable with a reason; TestPHPMethodMap fails on a missing, unknown or extra method and on a Go test name that does not exist."
+ - "Security review (D-11 asks for one): 19.5-SECURITY-REVIEW.md maps every T-19.5 threat of plans 01-04 to the passing test that pins its mitigation or to an accepted disposition with its reason; a defect found is fixed in its own commit with a test."
+ - "scripts/check-phase19.5.sh fails closed: a required test that fails, skips or does not run fails its stage; --self-test proves the detectors on planted output; --all runs every stage across summercms.go, sm-user-plugin, sm-sociallogin-plugin, sm-demo-app, sm-bm-app (Go and frontend) and fonoteka.go, and prints NOT RUN docker without a daemon unless PHASE195_REQUIRE_DOCKER=1 makes that a failure."
+ - "D-21 after the release: every production fix this plan commits after plan 02's release (a change to a file other than *_test.go, testdata/ or README.md in summercms.go, sm-user-plugin or sm-sociallogin-plugin) is contained in every pin that consumes it (the sociallogin and user gitlinks of sm-demo-app and sm-bm-app, fonoteka.go's user gitlink, and the versions sm-sociallogin-plugin's go.mod requires). Under the Task 6 go-ahead it ships as patch tags and the plugin's requires, then separate pointer-bump commits, in D-21 order. On hold, the SUMMARY records each unreleased fix as a deviation and a todo with a g15office task tracks the release."
+ artifacts:
+ - path: "scripts/check-phase19.5.sh"
+ provides: "fail-closed phase gate across six repositories"
+ contains: "PHASE195_REQUIRE_DOCKER"
+ - path: "../sm-sociallogin-plugin/testdata/PHP_METHOD_MAP.md"
+ provides: "case-by-case map of the PHP social-auth tests"
+ contains: "test_unverified_provider_email_cannot_hijack_existing_account"
+ - path: "../sm-sociallogin-plugin/php_method_map_test.go"
+ provides: "TestPHPMethodMap"
+ contains: "TestPHPMethodMap"
+ - path: "../sm-sociallogin-plugin/matching_test.go"
+ provides: "TestMatching, TestEmailVerified, TestNOAuth"
+ contains: "TestNOAuth"
+ - path: ".planning/phases/19.5-social-login-in-sm-user-plugin/19.5-SECURITY-REVIEW.md"
+ provides: "threat to test map for T-19.5-01..T-19.5-37"
+ contains: "T-19.5-10"
+ key_links:
+ - from: "scripts/check-phase19.5.sh"
+ to: "../sm-sociallogin-plugin/php_method_map_test.go"
+ via: "the sociallogin stage requires TestPHPMethodMap by name"
+ pattern: "TestPHPMethodMap"
+ - from: "../sm-sociallogin-plugin/php_method_map_test.go"
+ to: "../fonoteka.go/plugins/golem15/user/oauth_identities_rule_test.go"
+ via: "map rows naming user-plugin tests are checked against USER_PLUGIN's test sources"
+ pattern: "USER_PLUGIN"
+ - from: ".planning/phases/19.5-social-login-in-sm-user-plugin/19.5-VALIDATION.md"
+ to: "scripts/check-phase19.5.sh"
+ via: "the per-task map names the gate stage and test for each decision"
+ pattern: "check-phase19.5.sh"
+ - from: "../sm-sociallogin-plugin"
+ to: "../sm-demo-app/plugins/golem15/sociallogin"
+ via: "a post-release fix from Tasks 2-5 reaches the application through Task 7's patch tag and pointer bump (same for sm-bm-app, and for sm-user-plugin in all three applications); Task 7 checks every pin with merge-base --is-ancestor"
+ pattern: "merge-base --is-ancestor"
+ prohibitions:
+ - statement: "No production behaviour changes in this plan except fixes for defects a new test or the security review exposes, each in its own commit in the owning repository with the README change it needs."
+ status: unresolved
+ verification: judgment
+ - statement: "Tests never call a real provider, never contain a real client id, secret or token, and never log secrets; provider HTTP is faked through ProviderConfig.HTTPClient."
+ status: unresolved
+ verification: judgment
+ - statement: "A skipped Docker test is never counted as passed: the gate treats a skip of a required name as a failure, and the SUMMARY records NOT RUN where no daemon answered."
+ status: unresolved
+ verification: judgment
+ - statement: "Test commits are pushed only as plan 02's Task 5 reply authorised. Tags, framework pushes and application pointer bumps happen only in Task 7, under this plan's Task 6 reply, in D-21 order, and only for the post-release fixes in Task 5's table. Nothing is force-pushed or deployed."
+ status: unresolved
+ verification: judgment
+---
+
+## Phase Goal
+
+ROADMAP Goal (not in user-story format; no story invented, see plan 01): social sign-in matching the PHP user plugin's contract, delivered by `sm-sociallogin-plugin` (CONTEXT D-01). ROADMAP SC-4: the new code has unit tests, delivered in the phase's last plan.
+
+This plan's slice (plan 4 of 4, wave 4, D-25 "Unit tests and the PHP test map", the project's "unit tests are the last plan" rule): one fail-closed gate first (the tracer), then unit tests for the framework and the user plugin, then the plugin's decision-to-test map, then the PHP test suite mapped case by case, then the security review D-11 asks for, the whole gate green and VALIDATION.md completed. If a production fix landed after plan 02's release, a gated patch release carries it to the applications (Tasks 6 and 7, D-21).
+
+## Spec-less probe fallback
+
+spec-less probe fallback skipped: phase has no requirement IDs to probe (visible skip). `must_haves` derive from SC-1..SC-4 and D-01..D-25; prohibitions recalled from CONTEXT and RESEARCH are listed without descriptors.
+
+## Decisions taken in this plan
+
+- One Go test function per PHP test file with one subtest per PHP method (subtest names are the PHP method names without `test_`), as 19.4 did; the map has one row per PHP method and names the repository (`sociallogin` or `userplugin`).
+- PHP cases about the unlink guard and its race run in sm-user-plugin (the unlink lives there); flow, pending and link cases run in sm-sociallogin-plugin on Postgres with fake providers.
+- `OAuthPasswordBootstrapTest`'s relaxed change-password flow (setting a first password without the current one, the confirmation code and its disarm) has no counterpart in the Go user plugin and no CONTEXT decision in this phase: those methods map to not-applicable with that reason, `test_stolen_token_alone_cannot_complete_relaxed_password_change` maps to an adapted test proving that a password-less account cannot set a password through change-password with a token alone, and a pending todo records the gap for the application that needs the flow (with its g15office task per .planning/G15OFFICE.md).
+- `SocialAuthGateTest`'s two web-mode recursion cases map to adapted tests asserting that a pending divert always lands on a callback (default_callback when none was sent), because web mode is not ported (D-05).
+- `ProfileOAuthLinkTest` is an application test of the PHP reference site; its unlink cases map to the user-plugin rule tests, its profile cases to `OAuthIdentitiesIndex` and `APIArray`'s has_self_set_password, and its 422-for-no-identity case to adapted (the host's `WriteNotFound` chooses the body; the reference site's own route belongs to its cutover).
+- The gate's default user-plugin base for the additive check is `4c5d3b8` (the sm-user-plugin commit the phase started from, PATTERNS.md), overridable with PHASE195_USER_BASE.
+- Context budget and hand-offs (estimate 320k across 7 tasks; D-25 fixes the plan count, and the project's lean-planning rule accepts this size). Execution uses up to three contexts:
+ - The first agent runs Tasks 1-3. After Task 3's commits it writes `19.5-04-SUMMARY.md` as a draft, with the line `Status: draft (hand-off after Task 3)` under its title. The draft lists the commits per repository with hashes, the test names added, each gate stage and its state, the Docker NOT RUN notes, and the defects found with their fix commits. Then the first agent stops.
+ - A fresh agent runs Tasks 4 and 5. It reads this plan, the draft and the repositories on disk, and appends to the draft. Task 5 ends with the `## Post-release fixes` table. When the table is empty, this agent writes "nothing to propagate" under `Task 6 reply`, records Tasks 6 and 7 as not needed, and finalizes the SUMMARY. Otherwise it stops at the Task 6 checkpoint.
+ - After the reply, a continuation agent runs Task 7 from this plan and the draft, then finalizes the SUMMARY.
+ - If the first context runs low after Task 2, the executor hands Task 3 to a fresh agent the same way.
+- Post-release fixes (D-21): plan 02 Task 6 tags and pushes, and plan 03 pins the applications to those releases. A defect fix made by Tasks 2-5 therefore reaches the applications only through a new release. Task 5 lists such fixes against every pin. Task 6 asks the user for the go-ahead; its gate is blocking-human because tags and pushes always need the user, and auto-mode must never answer for them. Task 7 then releases in D-21 order: framework, sm-user-plugin, sm-sociallogin-plugin, then application pointer bumps as separate commits. Test-only commits and README-only changes need no release.
+
+## Artifacts this phase produces (this plan)
+
+- `scripts/check-phase19.5.sh` with modes `--go`, `--userplugin`, `--sociallogin`, `--demo`, `--bm`, `--fonoteka`, `--docker`, `--docs`, `--self-test`, `--all`; environment `PHASE195_ROOT`, `USER_PLUGIN`, `SOCIALLOGIN_PLUGIN`, `DEMO_APP`, `BM_APP`, `FONOTEKA`, `PHASE195_USER_BASE`, `PHASE195_REQUIRE_DOCKER`.
+- Framework tests: `TestPullConcurrent`, `TestPullExpired`, `TestPullPrefixIsolation` (pull_test.go), `TestPullDocker` (docker_test.go).
+- sm-user-plugin tests: `TestOAuthProviders`, `TestOAuthIdentitiesDestroyRule`, `TestOAuthIdentitiesDestroyRace`, `TestRegisterUserPasswordLess`, `TestFinishLogin`, `TestRegistrationExports`.
+- sm-sociallogin-plugin tests: `TestRegistry`, `TestStateCookie`, `TestNormalizeFrontendCallback`, `TestNormalizeReturnTo`, `TestCodes`, `TestRoutes`, `TestRouteMiddleware`, `TestProvidersList`, `TestMatching`, `TestEmailVerified`, `TestNOAuth`, `TestSoftDeleted`, `TestInactiveAndBanned`, `TestRegistrationGates`, `TestConsent`, `TestBetaGate`, `TestIdentityTokens`, `TestLink`, `TestLinkTicket`, `TestOAuthComplete`, `TestRegisterComplete`, `TestTokenCookieMode`, `TestSettings`, `TestGothProviders`, `TestErrorMessages`, `TestPHPSocialAuthGate`, `TestPHPOAuthLinkCookie`, `TestPHPPendingRegistrationTakeover`, `TestPHPPasswordBootstrap`, `TestPHPProfileOAuthLink`, `TestPHPMethodMap`; files `testdata/PHP_METHOD_MAP.md`, `testdata/php_methods.txt`.
+- `19.5-SECURITY-REVIEW.md`; `19.5-VALIDATION.md` per-task map filled with `nyquist_compliant: true` and `wave_0_complete: true`; todo `.planning/todos/pending/oauth-password-bootstrap-change-password.md`.
+- Only when Task 5's Post-release fixes table has a row, and only under the Task 6 reply:
+ - on "release": the next patch tags of the repositories with fixes (sm-user-plugin after v0.1.2, sm-sociallogin-plugin after v0.1.0, and the framework after plan 02's tag, with its changelog post) and the sm-sociallogin-plugin commit `chore: require `;
+ - on "release" or "push-only": pointer-bump commits `chore: bump sm-sociallogin-plugin to ` and `chore: bump sm-user-plugin to ` in sm-demo-app and sm-bm-app, and `chore: bump sm-user-plugin to ` in fonoteka.go;
+ - on "hold": todo `.planning/todos/pending/sociallogin-post-release-fixes.md` with its g15office task.
+
+
+Deliver the phase's unit tests (project rule: the last plan), map the PHP social-auth tests case by case, run the security review of the matching and state code, and ship a fail-closed gate that proves everything across the six repositories.
+
+Purpose: SC-4 and D-25 plan 4; regression proof for every decision; the D-11 security review.
+Output: test commits in summercms.go, sm-user-plugin and sm-sociallogin-plugin, the gate script, the security review and VALIDATION.md (planning doc commits), and the captured todo. When a fix lands after plan 02's release, it reaches every application that pins the release (patch tags and pointer bumps under the Task 6 go-ahead), or the user's hold is recorded as a deviation and a todo.
+
+
+
+@~/.claude/msd-core/workflows/execute-plan.md
+@~/.claude/msd-core/templates/summary.md
+
+
+
+@.planning/phases/19.5-social-login-in-sm-user-plugin/19.5-VALIDATION.md
+@.planning/phases/19.5-social-login-in-sm-user-plugin/19.5-01-SUMMARY.md
+@.planning/phases/19.5-social-login-in-sm-user-plugin/19.5-02-SUMMARY.md
+@.planning/phases/19.5-social-login-in-sm-user-plugin/19.5-03-SUMMARY.md
+
+Repos: summercms.go (cooler tests, gate, planning docs), ../fonoteka.go/plugins/golem15/user (sm-user-plugin), ../sm-sociallogin-plugin. The gate also runs ../sm-demo-app, ../sm-bm-app and ../fonoteka.go without editing them. The one exception is Task 7: under the Task 6 reply it bumps their plugin gitlinks and the sm-bm-app go.mod and go.sum lines the bump needs. In fonoteka.go's superproject Task 7 stages only plugins/golem15/user; never stage anything else there. Tests only, plus fixes for defects a new test or the review exposes (own commit, README change included). Push the plugin repositories' commits only when plan 02's reply allowed pushes and every unpushed commit is this phase's; otherwise leave them local and record it. Tags and pointer bumps follow Task 6.
+
+Hand-offs: see "Decisions taken in this plan", context budget. The first agent stops after Task 3, a fresh agent runs Tasks 4 and 5, and a continuation agent runs Task 7 after the Task 6 reply. Each one reads this plan and the 19.5-04-SUMMARY.md draft. No co-author tags; stage by path. Docker-backed tests skip under -short; run them on a Docker host (through sudo -u golem if the socket is denied) and record NOT RUN when no daemon answers.
+
+
+- PHP tests (read-only): /media/nvme/dev/jz/wavepath.org/plugins/golem15/user/tests/unit/components/SocialAuthGateTest.php (11 methods), tests/unit/components/OAuthLinkCookieTest.php (4), tests/security/OAuthPendingRegistrationTakeoverTest.php (5), tests/security/OAuthPasswordBootstrapTest.php (11), and /media/nvme/dev/jz/wavepath.org/plugins/golem15/wavepath/tests/feature/Auth/ProfileOAuthLinkTest.php (7): 38 methods in total.
+- Map analog: ../sm-oauthserver-plugin/php_method_map_test.go (readPHPMethods, live tree comparison through an env variable, readPHPMethodMap, Go test and t.Run name existence per repository) and ../sm-oauthserver-plugin/testdata/PHP_METHOD_MAP.md (table columns PHP method, PHP file, Go test, repository, status, reason).
+- Gate analog: scripts/check-phase19.4.sh (stages, go test -json detectors that fail on skip, zero tests, "no tests to run" and missing names, NOT RUN docker handling, --self-test with planted output, the additive-export check against a base commit).
+- Test seams from plans 01-03 (their SUMMARYs have final names): modules/cooler/pull_smoke_test.go, redis_test.go (miniredis), docker_test.go (redis:7-alpine); sm-user-plugin session_test.go helpers and oauth_identities_test.go helpers, social_hooks_smoke_test.go; sm-sociallogin-plugin harness_test.go (`bootSocial`, `freshDB`), fakeprovider_test.go (`fakeProvider`), the five smoke tests; sm-demo-app sociallogin_test.go; sm-bm-app sociallogin_test.go and frontend src/lib/oauth.test.ts.
+- 19.5-VALIDATION.md "Per-Task Verification Map" rows D-03, D-04, D-07, D-06/D-17, D-08, D-09, D-10, D-11, D-12, D-13, D-14, D-15, D-16, D-19, D-20, PHP map, D-21; the threat registers of 19.5-01..04-PLAN.md.
+
+
+
+
+
+
+ Task 1: Tracer: scripts/check-phase19.5.sh runs every repository's suite and the phase's smoke tests by name, and fails when one is missing, skipped or red
+ scripts/check-phase19.5.sh
+ scripts/check-phase19.4.sh (whole file), 19.5-01-SUMMARY.md, 19.5-02-SUMMARY.md, 19.5-03-SUMMARY.md (smoke test names and repositories), 19.5-VALIDATION.md
+ Create scripts/check-phase19.5.sh from check-phase19.4.sh's structure (header comment listing stages, `set -euo pipefail`, NO_COLOR, the go test -json detector that fails a stage on a failed or skipped test including subtests, zero matched tests, "no tests to run" or a missing required name, `--self-test` with planted output, `--all`). Defaults are the sibling checkouts: PHASE195_ROOT (this repository), USER_PLUGIN (../fonoteka.go/plugins/golem15/user, tested through the fonoteka.go workspace as in 19.4), SOCIALLOGIN_PLUGIN (../sm-sociallogin-plugin), DEMO_APP (../sm-demo-app), BM_APP (../sm-bm-app), FONOTEKA (../fonoteka.go), PHASE195_USER_BASE (default 4c5d3b8). Modes:
+- `--go`: go vet ./... and go test -short ./... here, then the Docker-free cooler names (TestPullSmoke now; Task 2 adds its names).
+- `--userplugin`: go vet and go test -short of the user plugin through the fonoteka.go workspace, Docker-free names (TestOAuthProvidersSourceSmoke), and the additive check: no line declaring an exported func, method or type removed from a non-test .go file since PHASE195_USER_BASE, and no file under updates/ changed (D-22).
+- `--sociallogin`: go vet and go test -short in the plugin, Docker-free names (TestGothProvidersSmoke), plus `go list -deps .` containing no github.com/gorilla/sessions and no gopkg.in/yaml.v3 and no Go file importing goth outside classes/providers_goth.go and its tests.
+- `--demo`: go build ./..., go vet ., go test -short . with TestBootAllPlugins, TestSocialLoginRedirect, TestSocialLoginCoexistsWithOAuthServer by name.
+- `--bm`: go build ./..., go vet ., go test -short . with TestSocialLoginMounted by name; go test -short of plugins/jz/bm with TestStaticReservedPaths and TestEmbeddedPublicHasIndex by name; `npm --prefix frontend run test` (vitest output must report oauth.test.ts as passed) and `npm --prefix frontend run typecheck`.
+- `--fonoteka`: go vet ./... and go test -short ./... of the workspace, and that summer.yaml does not mount golem15.sociallogin.
+- `--docker` (Docker host only, fails with "needs a reachable Docker daemon" otherwise): the Postgres and Redis named tests of plans 01-03 (TestSocialHooksSmoke, TestOAuthIdentitiesDestroyConcurrentSmoke, TestOAuthIdentitiesDestroyLastIdentityWithPassword, TestBackfillHandlesOpensFromConfigWhenUnpublished, TestTracerSmoke, TestFlowSmoke, TestLinkSmoke, TestSettingsOverrideSmoke) without -short.
+- `--docs`: the decision note names markbates/goth and v1.82.0; TestDocsTree; `go run ./cmd/summer docs:build --check`; no framework source, README or docs page under modules/ or docs/ names a consuming application (grep -i for plytarium, płytarium, fonoteka, wavepath, horoskopia, bm.jakubzych outside _test.go files and .planning).
+- `--all`: self-test, go, userplugin, sociallogin, demo, bm, fonoteka, docs, then docker when a daemon answers (NOT RUN docker otherwise; a failure with PHASE195_REQUIRE_DOCKER=1).
+Each later task of this plan appends its test names to the stage that runs them. `--self-test` covers the detectors (fail, skip, zero tests, missing name, a subtest pass not counting for its parent) and the docker NOT RUN rule on a faked PATH without docker. Commit in summercms.go: `test(19.5): fail-closed phase gate`.
+
+ bash -n scripts/check-phase19.5.sh && scripts/check-phase19.5.sh --self-test && scripts/check-phase19.5.sh --go && scripts/check-phase19.5.sh --sociallogin && scripts/check-phase19.5.sh --userplugin
+ Non-zero exit: a syntax error, a self-test detector that does not catch its planted failure, or a stage that finds a required smoke test missing, skipped or red, a removed user-plugin export, a changed user-plugin migration, or gorilla/sessions in the plugin's dependency graph.
+
+
+ - `grep -c 'PHASE195_REQUIRE_DOCKER' scripts/check-phase19.5.sh` prints at least 2.
+ - `grep -cE -- '^\s*--(go|userplugin|sociallogin|demo|bm|fonoteka|docker|docs|self-test|all)\)' scripts/check-phase19.5.sh` prints at least 10.
+ - `grep -c 'TestTracerSmoke' scripts/check-phase19.5.sh` prints at least 1 and `grep -c 'gorilla/sessions' scripts/check-phase19.5.sh` prints at least 1.
+ - `test -x scripts/check-phase19.5.sh` exits 0.
+
+ One command shows, for every repository the phase touched, whether its suite and the phase's named tests pass, and it cannot be fooled by a skipped or missing test.
+
+
+
+ Task 2: Unit tests for cooler.Cache.Pull and the sm-user-plugin additions (provider source, unlink rule and race, password-less registration, FinishLogin, exported helpers), wired into the gate
+ modules/cooler/pull_test.go, modules/cooler/docker_test.go, ../fonoteka.go/plugins/golem15/user/oauth_providers_test.go, ../fonoteka.go/plugins/golem15/user/oauth_identities_rule_test.go, ../fonoteka.go/plugins/golem15/user/social_hooks_test.go, scripts/check-phase19.5.sh
+ modules/cooler/pull_smoke_test.go, modules/cooler/memory_test.go (:207 TestMemoryIncrementConcurrent), modules/cooler/redis_test.go, modules/cooler/docker_test.go (:52-90), ../fonoteka.go/plugins/golem15/user/oauth_identities_test.go, ../fonoteka.go/plugins/golem15/user/oauth_providers_smoke_test.go, ../fonoteka.go/plugins/golem15/user/social_hooks_smoke_test.go, ../fonoteka.go/plugins/golem15/user/session_test.go (helpers), ../fonoteka.go/plugins/golem15/user/controllers/oauth_identities.go, ../fonoteka.go/plugins/golem15/user/controllers/registration.go, ../fonoteka.go/plugins/golem15/user/controllers/api_controller.go (exports), 19.5-RESEARCH.md (Pitfall 1)
+ Framework (D-08), modules/cooler/pull_test.go: `TestPullConcurrent` with subtests memory and miniredis: 64 goroutines Pull one key behind a start barrier, exactly one gets found=true with the right bytes, the rest found=false and no error, repeated 20 times; `TestPullExpired`: an expired entry is not returned and is gone afterwards (memory: Get after Pull; miniredis: Exists false); `TestPullPrefixIsolation`: two caches with different prefixes on one miniredis, Pull on one never removes the other's key. docker_test.go gains `TestPullDocker` (redis:7-alpine like TestRedisCacheDocker, skip under -short): Set, Pull once found, second Pull not found, and the 64-goroutine single winner.
+
+sm-user-plugin (D-13, D-15, D-22 helpers), Postgres tests unless noted: oauth_providers_test.go `TestOAuthProviders` (no database, runs under -short): no source gives the exact old bytes; a source with no enabled providers gives `"providers":[]`; order preserved; the lookup happens per request (publish after constructing the handler and the next request sees it). oauth_identities_rule_test.go `TestOAuthIdentitiesDestroyRule` table: self-set password with one identity 204 and zero left; password-less with one 409 with the EN and PL bodies unchanged; password-less with two 204 and one left; unknown provider 404 through WriteNotFound; another user's identity 404; microsoft allowed with default options; opts.Providers empty and a published source with KnownOAuthProviders [google, custom] allows custom and refuses github; opts.Providers set wins over the source. `TestOAuthIdentitiesDestroyRace`: two explicit transactions on a password-less two-identity user (google, facebook), in both orders, each taking the row lock and running the guarded DELETE the handler runs, the second blocked until the first commits, then exactly one identity left; plus the handler-level variant from the smoke. social_hooks_test.go `TestRegisterUserPasswordLess` (PasswordLess false keeps has_self_set_password true; true gives false; handle assigned; validation errors returned unchanged), `TestFinishLogin` (trashed user restored with one reactivate mail; active user gets no mail; last_ip_address and last_seen set; empty ip leaves last_ip_address), `TestRegistrationExports` (RegistrationSettings defaults and config overrides; RegistrationsFromIP counts only the last 60 minutes and only that IP; RefreshTTL default 20160 minutes and config; RemoteIP with and without a port).
+
+Wire the names into the gate (`--go`: TestPullConcurrent, TestPullExpired, TestPullPrefixIsolation; `--userplugin`: TestOAuthProviders; `--docker`: TestPullDocker, TestOAuthIdentitiesDestroyRule, TestOAuthIdentitiesDestroyRace, TestRegisterUserPasswordLess, TestFinishLogin, TestRegistrationExports). Commits: summercms.go `test(cooler): Pull concurrency, expiry and prefix isolation` and gate update `test(19.5): gate the framework and user-plugin unit tests`; sm-user-plugin `test: provider source, unlink rule and race, sign-in helpers`.
+
+ go test -count=1 -short -run '^(TestPullConcurrent|TestPullExpired|TestPullPrefixIsolation)$' -v ./modules/cooler 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 3 && scripts/check-phase19.5.sh --go && scripts/check-phase19.5.sh --userplugin
+ The count of top-level "--- PASS: Test" lines is not 3 (a Pull test failed or was not found) or a gate stage exits non-zero.
+ scripts/check-phase19.5.sh --docker
+ Non-zero exit: a Postgres or Redis test failed, skipped or is missing, or "needs a reachable Docker daemon" (rerun through sudo -u golem; without any daemon record NOT RUN in the SUMMARY).
+
+
+ - `grep -c 'func TestPullConcurrent' modules/cooler/pull_test.go` prints at least 1 and `grep -c 'func TestPullDocker' modules/cooler/docker_test.go` prints at least 1.
+ - `grep -c 'func TestOAuthIdentitiesDestroyRace' ../fonoteka.go/plugins/golem15/user/oauth_identities_rule_test.go` prints at least 1 and `grep -c 'FOR UPDATE' ../fonoteka.go/plugins/golem15/user/oauth_identities_rule_test.go` prints at least 1.
+ - `grep -c '"microsoft"' ../fonoteka.go/plugins/golem15/user/oauth_identities_rule_test.go` prints at least 1.
+ - `grep -cE 'TestPullDocker|TestOAuthIdentitiesDestroyRace|TestFinishLogin' scripts/check-phase19.5.sh` prints at least 3.
+
+ The framework's atomic pull and the user plugin's new behaviour are pinned by unit tests that run in the gate.
+
+
+
+ Task 3: Unit tests for sm-sociallogin-plugin, one per decision in the validation map (registry, cookie, normalisers, codes, routes and throttles, matching, verified rules and nOAuth, gates, linking, completion, settings, goth wrappers, messages), wired into the gate
+ ../sm-sociallogin-plugin/registry_test.go, ../sm-sociallogin-plugin/statecookie_test.go, ../sm-sociallogin-plugin/normalize_test.go, ../sm-sociallogin-plugin/codes_test.go, ../sm-sociallogin-plugin/routes_test.go, ../sm-sociallogin-plugin/matching_test.go, ../sm-sociallogin-plugin/registration_test.go, ../sm-sociallogin-plugin/link_test.go, ../sm-sociallogin-plugin/complete_test.go, ../sm-sociallogin-plugin/register_complete_test.go, ../sm-sociallogin-plugin/settings_test.go, ../sm-sociallogin-plugin/providers_goth_test.go, ../sm-sociallogin-plugin/README.md, scripts/check-phase19.5.sh
+ 19.5-VALIDATION.md (Per-Task Verification Map), 19.5-02-SUMMARY.md, ../sm-sociallogin-plugin/harness_test.go, ../sm-sociallogin-plugin/fakeprovider_test.go, the five smoke tests, ../sm-sociallogin-plugin/classes/*.go, ../sm-sociallogin-plugin/controllers/*.go, ../sm-sociallogin-plugin/routes.go, ../sm-oauthserver-plugin/route_guard_test.go (route information assertions), 19.5-RESEARCH.md (Q4 tables, Pitfalls 3-6, 9-11, 14, 15, Security Domain)
+ One top-level test per row of the validation map (subtests for cases), Postgres through bootSocial where a database is involved, fakes through the Registry (D-03) and http.RoundTripper fakes for goth (no network):
+- registry_test.go `TestRegistry` (name pattern, every reserved name refused with ErrReservedName, duplicate refused, Names order, Label, Build unknown, Build passes the config to the factory) and `TestRoutes` (unknown and reserved names 404 without a cookie; registered but disabled 302 with provider_not_configured; invalid action 302 with invalid_action; mode ignored).
+- statecookie_test.go `TestStateCookie`: round trip; tampered byte, other cookie name as AAD, other provider, iat older than 600 s, iat 2 minutes in the future, wrong version and oversize all refused with one error; attributes HttpOnly, SameSite Lax, Path /oauth, Max-Age 600; Secure true when app.url is https and false on http in development; through the handlers: missing cookie, empty state and unequal state give error=invalid_state and the clearing Set-Cookie appears on every callback outcome (success, error, provider error).
+- normalize_test.go `TestNormalizeFrontendCallback` and `TestNormalizeReturnTo`: the full tables in this plan's must_haves (including allowed_hosts, frontend_url host, request host, uppercase host, port kept, userinfo dropped, javascript: and data: schemes, wrong path, //evil.example.test).
+- codes_test.go `TestCodes`: 64 characters URL-safe; values sealed (the raw cache bytes contain neither the token nor the user email); wrong AAD (value moved to another key) refused; TTLs 5 min, 10 min, 60 s; Pull consumes once.
+- routes_test.go `TestRouteMiddleware` (route information shows throttle:20,1 on both /oauth routes and throttle:user-api on the three /_user/api/v1 routes; the 21st /oauth request in a minute from one IP gets 429) and `TestProvidersList` (enabled only, registry order, label, empty without credentials, published under userclasses.OAuthProviderSource so the user plugin's oauth-providers sees it).
+- matching_test.go `TestMatching` (the D-10 table for action login and register: identity login, fb_no_email, unverified_match, verified link, no match pending, register creation, redirect parameter order with and without reason and email), `TestEmailVerified` (google verified_email true, false, missing, string "true" rejected, email_verified true; github primary verified; facebook false), `TestNOAuth` (Microsoft mail equal to an existing user's email, any tenant: pending unverified_match, no identity created, no login), `TestSoftDeleted` (trashed user restored on identity and on verified match; unverified match on a trashed user goes pending; no second users row for a trashed email at register-complete), `TestInactiveAndBanned` (not activated and banned refused on login, link and verified-match paths with their messages).
+- registration_test.go `TestRegistrationGates` (allow_registration false; the 4th registration from one IP within an hour with use_register_throttle; throttle off), `TestConsent` (terms or privacy missing, parental_authority sent false, consent 601 s old: each refused with its message; "1", "true", "on", "yes" accepted), `TestBetaGate` (empty key: no gate; wrong key refused at the callback and at register-complete; the key carried from the start through pending accepted; the key sent in the completion body accepted), `TestIdentityTokens` (AccessToken and RefreshToken stored as lagoon.Encrypted ciphertext, token_expires_at from the profile, nil when zero; profile_data holds name, email, avatar and nothing else; user avatar column untouched; the RegisterEvent payload holds terms_version, privacy_version, consent_ip_address, oauth_provider, password_bootstrap_source and no password).
+- link_test.go `TestLink` (bearer header, token cookie, auth_token cookie, blacklisted token and tokens_valid_after refused, trashed link user refused, identity of another user refused with the provider label in the message, relink of the same identity updates tokens) and `TestLinkTicket` (401 body without auth; ticket single use; expired after 60 s; a ticket is never accepted as a bearer token; no JWT in any Location header of the flow).
+- complete_test.go `TestOAuthComplete` (422, 410 unknown, 200 shape with defaults, 410 on reuse, Cache-Control no-cache, private) and `TestTokenCookieMode` (token_cookie true: Set-Cookie token with bouncer's attributes and Max-Age from RefreshTTL, no token key in oauth-complete and oauth-register-complete bodies).
+- register_complete_test.go `TestRegisterComplete` (422 missing code; 410 bad code; consent 422 with both errors keys in pl and en and the code kept; beta 422 keeps the code; email_exists_link_account 422 with the provider label keeps the code; payload without email_verified fails closed; verified match logs in; creation returns action register; 16 concurrent submits of one code yield exactly one 200 and 15 410s and one user).
+- settings_test.go `TestSettings` (config only; row overrides id and secret per field; tenant from row; enabled iff id and secret; change visible on the next request; secret absent from the admin settings JSON and from any log line captured during the test; legacy env names copied only onto empty keys).
+- providers_goth_test.go `TestGothProviders` (per provider: Begin URL host, path, scopes and prompt or tenant; Complete through the RoundTripper fake; github /user/emails selection and the no-verified-email case; microsoft userPrincipalName fallback and empty avatar; invalid_grant and bad_verification_code wrap ErrCodeExpired; a stalled fake times out within the configured http_timeout; no request goes to a host outside the provider's list).
+- `TestErrorMessages`: every lang key used by the code exists in en and pl, and the PHP-identical texts (invalid_state, code_expired, registration_disabled, the pl consent message) match RESEARCH Q4 byte for byte.
+Fix any defect a test exposes in its own commit (README updated when behaviour or configuration changes). Add every name to the gate (`--sociallogin` for the Docker-free ones: TestRegistry, TestNormalizeFrontendCallback, TestNormalizeReturnTo, TestGothProviders, TestErrorMessages and any other that needs no database; `--docker` for the rest). README Testing lists the tests. Commits in the plugin: `test: unit tests for every social sign-in decision`; summercms.go: `test(19.5): gate the plugin unit tests`.
+
+Hand-off point: after these commits, write the 19.5-04-SUMMARY.md draft described under "Decisions taken in this plan" (context budget). This context ends here, and a fresh agent runs Tasks 4 and 5.
+
+ go -C ../sm-sociallogin-plugin vet ./... && go -C ../sm-sociallogin-plugin test -count=1 -short ./... && scripts/check-phase19.5.sh --sociallogin
+ Non-zero exit: vet, a Docker-free plugin test, or the sociallogin gate stage (a required name failed, skipped or is missing) is red.
+ scripts/check-phase19.5.sh --docker
+ Non-zero exit: a Postgres-backed plugin test failed, skipped or is missing, or no Docker daemon answered (rerun through sudo -u golem; otherwise record NOT RUN in the SUMMARY).
+
+
+ - `grep -hoE '^func Test[A-Za-z]+' ../sm-sociallogin-plugin/*_test.go | sort -u | grep -cxE 'func (TestRegistry|TestStateCookie|TestNormalizeFrontendCallback|TestNormalizeReturnTo|TestCodes|TestRoutes|TestRouteMiddleware|TestProvidersList|TestMatching|TestEmailVerified|TestNOAuth|TestSoftDeleted|TestInactiveAndBanned|TestRegistrationGates|TestConsent|TestBetaGate|TestIdentityTokens|TestLink|TestLinkTicket|TestOAuthComplete|TestTokenCookieMode|TestRegisterComplete|TestSettings|TestGothProviders|TestErrorMessages)'` prints at least 25 (all 25 names exist).
+ - `grep -cE 'TestNOAuth|TestRegisterComplete|TestStateCookie' scripts/check-phase19.5.sh` prints at least 3.
+ - `grep -c 'http.DefaultClient\|http.DefaultTransport' ../sm-sociallogin-plugin/providers_goth_test.go ../sm-sociallogin-plugin/providers_smoke_test.go | awk -F: '{s+=$2} END {print s}'` prints 0 (provider tests only use the fake transport), and TestGothProviders has a subtest proving the fake transport refuses a host outside its table.
+
+ Every decision of the plugin has a unit test that runs in the gate, including the nOAuth case, the consent and beta gates, the single-winner completion and the state-cookie failure modes.
+
+
+
+ Task 4: The PHP social-auth test suite mapped case by case (38 methods) with TestPHPMethodMap, the PHP-named port tests, and the password-bootstrap gap recorded as a todo
+ ../sm-sociallogin-plugin/testdata/php_methods.txt, ../sm-sociallogin-plugin/testdata/PHP_METHOD_MAP.md, ../sm-sociallogin-plugin/php_method_map_test.go, ../sm-sociallogin-plugin/php_port_test.go, ../sm-sociallogin-plugin/README.md, .planning/todos/pending/oauth-password-bootstrap-change-password.md, scripts/check-phase19.5.sh
+ 19.5-04-SUMMARY.md draft (hand-off from Tasks 1-3: commits, test names, gate state), ../sm-oauthserver-plugin/php_method_map_test.go, ../sm-oauthserver-plugin/testdata/PHP_METHOD_MAP.md, /media/nvme/dev/jz/wavepath.org/plugins/golem15/user/tests/unit/components/SocialAuthGateTest.php, /media/nvme/dev/jz/wavepath.org/plugins/golem15/user/tests/unit/components/OAuthLinkCookieTest.php, /media/nvme/dev/jz/wavepath.org/plugins/golem15/user/tests/security/OAuthPendingRegistrationTakeoverTest.php, /media/nvme/dev/jz/wavepath.org/plugins/golem15/user/tests/security/OAuthPasswordBootstrapTest.php, /media/nvme/dev/jz/wavepath.org/plugins/golem15/wavepath/tests/feature/Auth/ProfileOAuthLinkTest.php, ../fonoteka.go/plugins/golem15/user/profile_test.go (TestChangePassword :46), ../fonoteka.go/plugins/golem15/user/controllers/api_controller.go (ChangePassword refuses a password-less account :503), .planning/G15OFFICE.md, .planning/todos/pending (frontmatter style)
+ testdata/php_methods.txt lists the 38 methods as `::` (SocialAuthGateTest 11, OAuthLinkCookieTest 4, OAuthPendingRegistrationTakeoverTest 5, OAuthPasswordBootstrapTest 11, ProfileOAuthLinkTest 7). php_method_map_test.go `TestPHPMethodMap` (copy of the oauthserver checker): the list has 38 entries; when the PHP trees exist (env PHASE195_PHP_USER, default /media/nvme/dev/jz/wavepath.org/plugins/golem15/user/tests, and PHASE195_PHP_PROFILE for the ProfileOAuthLinkTest file) the live methods equal the list; every list entry has exactly one map row and no row is extra; every `ported` or `adapted` row names a Go test that exists as `func (` in its repository (`sociallogin`: this repository; `userplugin`: the USER_PLUGIN directory, default ../fonoteka.go/plugins/golem15/user) and its subtest as a `t.Run(""` there; every `not-applicable` and `adapted` row has a reason.
+
+php_port_test.go, one top-level test per PHP file with one subtest per PHP method (name without `test_`), on Postgres with fake providers: `TestPHPSocialAuthGate` (the 11 gate cases; the two web-mode recursion cases adapted to "a pending divert always lands on a callback, default_callback when none was sent", D-05), `TestPHPOAuthLinkCookie` (auth_token cookie and token cookie resolve the link user; a non-JWT auth_token value resolves nobody (Go has no cookie-encryption middleware, adapted); no cookie gives must_be_logged_in), `TestPHPPendingRegistrationTakeover` (all 5 ported), `TestPHPPasswordBootstrap` (oauth_registration_marks_new_account_password_less ported; stolen_token_alone_cannot_complete_relaxed_password_change adapted: a password-less account's POST change-password with a valid token and no current password does not change the password; the unlink guard and race cases point to the user-plugin tests TestOAuthIdentitiesDestroyRule and TestOAuthIdentitiesDestroyRace in the map rather than duplicating them), `TestPHPProfileOAuthLink` (APIArray exposes has_self_set_password; OAuthIdentitiesIndex lists one and then two linked providers after two links; the unlink cases point to the user-plugin rule test; the 422-without-identity case adapted to the host's WriteNotFound). PHP_METHOD_MAP.md: header like the oauthserver map, then one row per method with status ported, adapted or not-applicable; `test_oauth_account_sets_password_without_current_password`, `test_disarm_closes_the_no_current_password_window` and `test_confirmation_code_is_single_use_and_time_limited` are not-applicable with the reason "the Go user plugin has no relaxed change-password bootstrap (no CONTEXT decision in Phase 19.5); password-less accounts set a password through forgot and reset; tracked in .planning/todos/pending/oauth-password-bootstrap-change-password.md"; `test_real_password_account_still_requires_current_password` maps to the existing user-plugin TestChangePassword (subtest name as it exists, else add a subtest there in its own commit).
+
+Todo (summercms.go): `.planning/todos/pending/oauth-password-bootstrap-change-password.md` in the existing todo frontmatter style (title, date 2026-10-20 or the execution date, priority medium, area sm-user-plugin): social sign-in creates password-less accounts (D-10); the PHP user plugin lets such accounts set a first password through change-password with an emailed confirmation code and a disarm step (OAuthPasswordBootstrapTest), while the Go change-password answers an opaque 500 for them and only forgot/reset works; an application cutover that relies on the PHP flow needs it. Create its g15office task as .planning/G15OFFICE.md requires and write the ID into the frontmatter (if the tool is unavailable, record that in the SUMMARY). Commit alone: `docs(19.5): capture the OAuth password-bootstrap todo`.
+
+Gate: `--sociallogin` requires TestPHPMethodMap (Docker-free); `--docker` requires the five TestPHP* tests. Commits in the plugin: `test: PHP social-auth test map and ports`; summercms.go: `test(19.5): gate the PHP map`.
+
+ go -C ../sm-sociallogin-plugin test -count=1 -short -run '^TestPHPMethodMap$' -v . 2>&1 | grep -q -- '^--- PASS: TestPHPMethodMap' && scripts/check-phase19.5.sh --sociallogin
+ No "--- PASS: TestPHPMethodMap" line (a PHP method is unmapped, a row is extra, or a named Go test or subtest does not exist) or the sociallogin gate stage exits non-zero.
+ go -C ../sm-sociallogin-plugin test -count=1 -run '^(TestPHPSocialAuthGate|TestPHPOAuthLinkCookie|TestPHPPendingRegistrationTakeover|TestPHPPasswordBootstrap|TestPHPProfileOAuthLink)$' -v . 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 5
+ The count of top-level "--- PASS: Test" lines is not 5: a PHP port failed or skipped (Docker unreachable: rerun through sudo -u golem, else record NOT RUN).
+
+
+ - `N=$(grep -c '::test_' ../sm-sociallogin-plugin/testdata/php_methods.txt) && test "$N" -ge 38` exits 0.
+ - `grep -c '^| test_' ../sm-sociallogin-plugin/testdata/PHP_METHOD_MAP.md` prints at least 38.
+ - `grep -c 'test_unverified_provider_email_cannot_hijack_existing_account' ../sm-sociallogin-plugin/testdata/PHP_METHOD_MAP.md` prints at least 1.
+ - `grep -c 'not-applicable' ../sm-sociallogin-plugin/testdata/PHP_METHOD_MAP.md` prints at least 3 and every such row has a non-empty reason (`awk -F'|' '/not-applicable/ && $7 ~ /^ *$/' ../sm-sociallogin-plugin/testdata/PHP_METHOD_MAP.md | grep -c .` prints 0).
+ - `test -f .planning/todos/pending/oauth-password-bootstrap-change-password.md` exits 0.
+
+ Every PHP social-auth test has a Go counterpart or a stated reason, the checker keeps the map honest, and the one PHP flow this phase does not port is on the todo list.
+
+
+
+ Task 5: Security review of the matching and state code with every threat pinned by a test, the whole gate green across six repositories, and the validation map completed
+ .planning/phases/19.5-social-login-in-sm-user-plugin/19.5-SECURITY-REVIEW.md, .planning/phases/19.5-social-login-in-sm-user-plugin/19.5-VALIDATION.md, scripts/check-phase19.5.sh
+ 19.5-04-SUMMARY.md draft (commits and defects of Tasks 1-4), 19.5-02-SUMMARY.md (Task 5 reply, tags), 19.5-03-SUMMARY.md (the commits the applications pin), the threat_model blocks of 19.5-01..04-PLAN.md, 19.5-RESEARCH.md (Security Domain), .planning/phases/19.4-oidc-provider-on-wristband/19.4-SECURITY-REVIEW.md (format), 19.5-VALIDATION.md, ../sm-sociallogin-plugin/classes/flow.go, classes/statecookie.go, classes/callback.go, classes/codes.go, classes/providers_goth.go, controllers/*.go, ../fonoteka.go/plugins/golem15/user/controllers/oauth_identities.go, .planning/G15OFFICE.md
+ Security review (D-11 asks for one on the matching and state code; ASVS level 1, block on high). Read the code against every T-19.5 threat of plans 01-04 (T-19.5-01..T-19.5-37 and T-19.5-SC; T-19.5-36 and T-19.5-37 are release controls, recorded with Task 7's acceptance criteria instead of a test) and RESEARCH's Security Domain table, and write 19.5-SECURITY-REVIEW.md in the 19.4 format: per threat the component, the mitigation as implemented (file and function), the test that fails if the mitigation is removed (test name and repository), and the verdict (closed, accepted with reason). Check at least: state compare is constant time and rejects empty state; the cookie key is domain-separated from lagoon's; the callback target is rebuilt, never echoed; every login path checks activation and ban; Microsoft and Facebook can never set EmailVerified; register-complete decides before it pulls; link tickets are single use; no JWT, provider token, secret, code or RetrieveError body reaches a log line (list every non-test slog call of the plugin and of this phase's user-plugin changes, with the attributes it logs, under a `## Log calls` heading of the review); the unlink lock is taken before the DELETE; goth has a timeout on every provider. A threat without a failing-when-broken test gets one (in the owning repository, own commit) or an explicit accepted disposition. A defect gets a fix commit with its test and README change.
+
+Run `scripts/check-phase19.5.sh --all` on a Docker host (sudo -u golem if needed) until every stage passes; record the output summary (each stage PASS, or NOT RUN docker with the reason) in the SUMMARY. Fill 19.5-VALIDATION.md: the Per-Task Verification Map gets one row per task of plans 01-04 (task ID, decision, test names, the gate stage that runs them, status green), `nyquist_compliant: true`, `wave_0_complete: true`, status validated, and the sign-off checklist ticked; the manual-only row (live sign-in with real provider credentials in the demo and in bm) stays for /msd-verify-work. Tasks 6 and 7 are remote operations: their rows name Task 7's acceptance criteria instead of a test. Commit the review and VALIDATION.md together as a planning-doc commit (`docs(19.5): security review and validation map`), the gate changes separately.
+
+Last, add a `## Post-release fixes` table to the SUMMARY draft (D-21).
+- Rows: every commit of this plan in summercms.go, sm-user-plugin or sm-sociallogin-plugin that changes a file other than *_test.go, testdata/ or README.md.
+- Pins: the gitlinks plugins/golem15/sociallogin and plugins/golem15/user of ../sm-demo-app and ../sm-bm-app; plugins/golem15/user of ../fonoteka.go; and, for summercms.go and sm-user-plugin rows, the version ../sm-sociallogin-plugin/go.mod requires.
+- Columns: repository, commit hash, subject, and each pin that does not contain the commit (`git -C merge-base --is-ancestor ` exits 1).
+An empty table means nothing to propagate. In that case write "nothing to propagate" under a `Task 6 reply` heading, and the plan ends after this task: Tasks 6 and 7 are recorded as not needed. Then post a short comment on the Phase 19.5 milestone task in g15office (`onunbz3l`). It says the tests, the PHP map and the gate are done, and either that the phase is ready for verification or that a patch release awaits the Task 6 go-ahead. If the tool is unavailable, record that in the SUMMARY.
+
+ scripts/check-phase19.5.sh --all
+ Non-zero exit: any stage (self-test, go, userplugin, sociallogin, demo, bm, fonoteka, docs, or docker when a daemon answers) reports FAIL, a required test is missing or skipped, or PHASE195_REQUIRE_DOCKER=1 is set and no daemon answered.
+
+
+ - `grep -cE 'T-19\.5-(0[1-9]|[12][0-9]|3[0-7])' .planning/phases/19.5-social-login-in-sm-user-plugin/19.5-SECURITY-REVIEW.md` prints at least 30 and `grep -c 'T-19.5-SC' .planning/phases/19.5-social-login-in-sm-user-plugin/19.5-SECURITY-REVIEW.md` prints at least 1.
+ - `grep -c '^nyquist_compliant: true' .planning/phases/19.5-social-login-in-sm-user-plugin/19.5-VALIDATION.md` prints at least 1 and `grep -c '^wave_0_complete: true' .planning/phases/19.5-social-login-in-sm-user-plugin/19.5-VALIDATION.md` prints at least 1.
+ - `grep -c 'check-phase19.5.sh' .planning/phases/19.5-social-login-in-sm-user-plugin/19.5-VALIDATION.md` prints at least 1.
+ - `grep -c '^## Log calls' .planning/phases/19.5-social-login-in-sm-user-plugin/19.5-SECURITY-REVIEW.md` prints at least 1 (the review lists every non-test slog call of the plugin and of this phase's user-plugin changes with the attributes it logs, and none logs a secret, token, code, ticket or error body).
+ - `grep -c '^## Post-release fixes' .planning/phases/19.5-social-login-in-sm-user-plugin/19.5-04-SUMMARY.md` prints 1, and the section has a table row per post-release fix or the line "nothing to propagate".
+
+ Every threat of the phase is closed by a test or accepted with a reason, the gate is green across the six repositories, and the validation map shows which test proves which decision.
+
+
+
+ Task 6: Go-ahead to release this plan's post-release fixes to the applications that pin plan 02's release (presented only when Task 5's Post-release fixes table has a row)
+ 19.5-04-SUMMARY.md draft (Post-release fixes), 19.5-02-SUMMARY.md (Task 5 reply, tags), 19.5-03-SUMMARY.md (pinned commits), 19.5-CONTEXT.md (D-21), CLAUDE.md (Commits and concurrency, changelog rule)
+
+ - The SUMMARY records the reply verbatim under a "Task 6 reply" heading. When the table is empty, the checkpoint is not presented and that heading says "nothing to propagate".
+ - Before the reply, `T=$(git -C ../sm-sociallogin-plugin tag --points-at master) && test -z "$T"` and `T=$(git -C ../fonoteka.go/plugins/golem15/user tag --points-at master) && test -z "$T"` exit 0 (no patch tag before the go-ahead).
+
+ Do the fixes this plan made after plan 02's release go out to the applications, and how?
+ Plan 02 Task 6 published the framework, sm-user-plugin and sm-sociallogin-plugin, and plan 03 pinned sm-demo-app, sm-bm-app and fonoteka.go to those commits. The Post-release fixes table lists every production fix this plan committed afterwards (repository, hash, subject, the found-by test or review threat) and each pin that lacks it. Without a release, the applications keep running the unfixed code. D-21 order applies: framework, then sm-user-plugin, then sm-sociallogin-plugin, then application pointer bumps as separate commits. Every tag and push needs your go-ahead (CLAUDE.md). A framework tag also needs a changelog post the same day. Proposed versions are the next patch after each existing tag: sm-user-plugin after v0.1.2, sm-sociallogin-plugin after v0.1.0, and the framework after plan 02's tag. If plan 02's reply was "push-only", no tags exist, and only "push-only" and "hold" apply. Nothing is deployed in any option.
+
+
+
+
+
+ Reply "release", "push-only" or "hold" (optionally naming the versions). Nothing is ever force-pushed or deployed.
+
+
+
+ Task 7: Under the Task 6 reply, every post-release fix reaches each application pin in D-21 order: patch tags, the plugin's requires, then separate pointer-bump commits in sm-demo-app, sm-bm-app and fonoteka.go
+ 19.5-04-SUMMARY.md (draft) has a "Task 6 reply" heading with "release", "push-only", "hold" or "nothing to propagate"; on "nothing to propagate" this task records "no post-release fix" and does nothing else.
+ ../sm-sociallogin-plugin/go.mod, ../sm-sociallogin-plugin/go.sum, ../sm-sociallogin-plugin/README.md, ../sm-demo-app/plugins/golem15/sociallogin, ../sm-demo-app/plugins/golem15/user, ../sm-bm-app/plugins/golem15/sociallogin, ../sm-bm-app/plugins/golem15/user, ../sm-bm-app/go.mod, ../sm-bm-app/go.sum, ../fonoteka.go/plugins/golem15/user, .planning/todos/pending/sociallogin-post-release-fixes.md
+ 19.5-04-SUMMARY.md draft (Post-release fixes, Task 6 reply), 19.5-02-SUMMARY.md (tags, Task 6 release steps), 19.5-02-PLAN.md Task 6 (the release procedure this task repeats for patches), 19.5-03-SUMMARY.md (how the pointers were bumped), CLAUDE.md (Commits and concurrency, changelog rule), ../sm-summercmsio-app/DEPLOY.md ("Posting to the changelog"), .planning/G15OFFICE.md
+ Per D-21 and the Task 6 reply, act only for the repositories and pins in the Post-release fixes table. Before every push:
+- run `git fetch origin`;
+- `git status` shows no uncommitted change to this phase's paths;
+- `git log --oneline origin/master..master` lists only this phase's commits (in summercms.go, only those and the commits plan 02's Task 5 context named). Otherwise stop and report.
+Never force-push, never deploy, and stage by path.
+
+On "release", in this order:
+1. summercms.go, when it has a row: go vet and go test -short are green. Create an annotated tag at the next patch after plan 02's framework tag, with a message naming the fix, then push master and the tag. Post the changelog the same day from ../sm-summercmsio-app: run `scripts/changelog-post.py source`, write user-facing notes with a "## Fixed" section to a file in the session scratchpad, then run `scripts/changelog-post.py publish` with an excerpt. Never print or mint the token; ask the user when the token file is missing or the token is refused.
+2. sm-user-plugin (../fonoteka.go/plugins/golem15/user), when it has a row: push master, create an annotated tag at the next patch after v0.1.2, and push the tag.
+3. sm-sociallogin-plugin, whenever any repository has a row:
+ - when step 1 or 2 tagged, make go.mod require the new versions (keep the summercms replace), run go mod tidy, keep vet and the short tests green, and name the versions in README Dependencies; commit `chore: require `;
+ - push master, create an annotated tag at the next patch after v0.1.0, and push it.
+4. Applications, one commit per pointer:
+ - in ../sm-demo-app and ../sm-bm-app, fetch and check out the new sm-sociallogin-plugin tag in plugins/golem15/sociallogin and, when step 2 tagged, the new sm-user-plugin tag in plugins/golem15/user, the way 19.5-03-SUMMARY records plan 03's bumps;
+ - in sm-bm-app, add the go.mod and go.sum change Go asks for (go mod tidy) to the commit it belongs to;
+ - commit `chore: bump sm-sociallogin-plugin to ` and `chore: bump sm-user-plugin to `;
+ - in ../fonoteka.go, stage only plugins/golem15/user and commit `chore: bump sm-user-plugin to `; other sessions' feedback and golem pointer changes stay untouched;
+ - run each application's gate stage (--demo, --bm, --fonoteka) green before pushing it.
+
+On "push-only": do steps 1-4 without tags, without the changelog post and without changing the requires. The pointers move to the pushed fix commits. Record the D-08 deviation: the plugin's requires keep plan 02's versions.
+
+On "hold": push nothing, tag nothing and move no pointer. In the SUMMARY, record a deviation that lists each unreleased fix and the pins that lack it. Create `.planning/todos/pending/sociallogin-post-release-fixes.md` in the existing todo frontmatter style: priority high when a row came from the security review, else medium; the body holds the table and this task's release steps. Create its g15office task as .planning/G15OFFICE.md requires and write the ID into the frontmatter; if the tool is unavailable, record that in the SUMMARY. Commit the todo alone: `docs(19.5): capture the post-release fix release todo`.
+
+Finalize the SUMMARY with the tags, the pushed ranges, the changelog post URL, the pointer-bump commits per application and the final pins.
+
+ scripts/check-phase19.5.sh --sociallogin && scripts/check-phase19.5.sh --demo && scripts/check-phase19.5.sh --bm && scripts/check-phase19.5.sh --fonoteka
+ Non-zero exit: the plugin or an application no longer builds or passes at the new requires and pointers, or a required test is missing or skipped. On "nothing to propagate" or "hold" the command is not run, and the SUMMARY says so.
+
+
+ - On "release" or "push-only", every table row passes this check: for fix commit F in repository R and gitlink P in application A, `git -C R merge-base --is-ancestor F "$(git -C A rev-parse HEAD:P)"` exits 0.
+ - On "release", the tags exist on the remotes:
+ - `git -C ../sm-sociallogin-plugin ls-remote --exit-code --tags origin ` exits 0;
+ - for a user-plugin row, `git -C ../fonoteka.go/plugins/golem15/user ls-remote --exit-code --tags origin ` exits 0, and `grep -c 'git.golem15.com/golem15/sm-user-plugin ' ../sm-sociallogin-plugin/go.mod` prints at least 1;
+ - for a summercms.go row, `git ls-remote --exit-code --tags origin ` exits 0, and the SUMMARY has the changelog post URL.
+ - For every application committed here, `L=$(git -C log --oneline origin/master..master) && test -z "$L"` exits 0. The fonoteka.go bump passes `git -C ../fonoteka.go show --name-only --format= `: the output is only plugins/golem15/user.
+ - On "hold", `test -f .planning/todos/pending/sociallogin-post-release-fixes.md` exits 0, and the SUMMARY's deviations name every table row.
+
+ Every fix found by this plan's tests or security review runs in each application that pins the release, or the user held the release and the gap is tracked as a deviation and a todo.
+
+
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+|----------|-------------|
+| test code to production code | tests must exercise the real handlers and SQL, not re-implementations that could pass while production fails |
+| gate script to developer machine | the gate runs commands across sibling repositories and reads environment overrides |
+| security review to the phase verdict | a review that marks a threat closed without a failing-when-broken test hides risk |
+
+## STRIDE Threat Register
+
+| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
+|-----------|----------|-----------|----------|-------------|-----------------|
+| T-19.5-31 | Repudiation | scripts/check-phase19.5.sh (false green) | medium | mitigate | go test -json detectors fail on skip, zero tests, missing names and "no tests to run"; --self-test proves each detector on planted output; NOT RUN docker is printed, and PHASE195_REQUIRE_DOCKER=1 turns it into a failure |
+| T-19.5-32 | Tampering | race tests (D-13, D-08) | medium | mitigate | the races are forced with explicit transactions and start barriers, run in both orders and repeated, so a missing lock or a non-atomic Pull fails deterministically instead of by chance |
+| T-19.5-33 | Elevation of privilege | security review completeness | high | mitigate | every T-19.5 ID of plans 01-04 must appear in 19.5-SECURITY-REVIEW.md with a named failing-when-broken test or an accepted reason (acceptance criterion counts them); defects get fix commits with tests |
+| T-19.5-34 | Information disclosure | test fixtures and logs | medium | mitigate | only test-only literal secrets; TestSettings captures log output and asserts no secret; no real provider host is dialled (fake transport host table) |
+| T-19.5-35 | Spoofing | PHP map honesty | low | mitigate | TestPHPMethodMap compares the list with the live PHP trees when present and checks every named Go test and subtest exists in its repository |
+| T-19.5-36 | Elevation of privilege | post-release fixes (Tasks 5-7) | high | mitigate | Task 5 lists each production fix with every pin that lacks it (merge-base --is-ancestor); the blocking-human Task 6 asks for the go-ahead; Task 7 releases in D-21 order, and its acceptance criteria require every pin to contain every fix. On hold, the open gap is a recorded deviation plus a todo with a g15office task, so a security fix never silently misses the applications |
+| T-19.5-37 | Tampering | remote operations in Task 7 | medium | mitigate | runs only after the Task 6 reply (never auto-approved); git fetch and an origin/master..master check before every push, which stops on any commit not of this phase; never force-pushed or deployed; in fonoteka.go only the plugins/golem15/user gitlink is staged |
+| T-19.5-SC | Tampering | module installs | high | mitigate | no module is installed in this plan; tests reuse miniredis and testcontainers already required; the gate checks the plugin's dependency graph for gorilla/sessions and gopkg.in/yaml.v3 |
+
+
+
+- Task 1: --self-test, --go, --sociallogin, --userplugin.
+- Task 2: the cooler unit tests by name, --go, --userplugin, --docker.
+- Task 3: plugin vet and short suite, --sociallogin, --docker.
+- Task 4: TestPHPMethodMap, the five TestPHP* ports.
+- Task 5: --all green; review and VALIDATION.md complete; Post-release fixes table written.
+- Task 6: reply recorded, or "nothing to propagate". Task 7: the app gate stages at the new pointers, plus a merge-base check per fix and pin.
+
+
+
+- SC-4: unit tests delivered in the last plan for all new code.
+- The PHP social-auth suite is mapped case by case.
+- The D-11 security review pins every threat with a test or an accepted reason.
+- One fail-closed command proves the phase across the six repositories.
+- D-21: a fix made after the release reaches every application pin under the user's go-ahead, or the hold is recorded and tracked.
+
+
+