docs(19.5): gap-closure plan 05 for email-proof auto-link and bm callback view test

This commit is contained in:
Jakub Zych
2026-10-21 11:53:54 +02:00
parent e6b20e2cb6
commit 71b99317ad
3 changed files with 681 additions and 1 deletions

View File

@@ -1363,7 +1363,7 @@ 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:** 4/4 plans executed
**Plans:** 4/5 plans executed
Plans:
**Wave 1**
@@ -1378,6 +1378,9 @@ Plans:
**Wave 4** *(blocked on Wave 3 completion)*
- [x] 19.5-04-PLAN.md — Unit tests for every decision, PHP social-auth test map, security review, scripts/check-phase19.5.sh (wave 4)
**Wave 5** *(gap closure, blocked on Wave 4 completion)*
- [ ] 19.5-05-PLAN.md — Gap closure (D-26..D-28): `users.email_verified_at` proof in sm-user-plugin, auto-link only onto a proven account in sociallogin, bm OAuthCallbackView vitest test, gate extended, release v0.1.5 / v0.1.2 and pointer bumps under the go-ahead (wave 5)
### Phase 19.6: Two-factor auth and device flow in sm-user-plugin (INSERTED)
**Goal:** `sm-user-plugin` has the PHP user plugin's second factors and device login: TOTP, WebAuthn, email codes, recovery codes and trusted devices, a challenge step during login, and QR/code device authorization.

View File

@@ -0,0 +1,671 @@
---
phase: 19.5-social-login-in-sm-user-plugin
plan: 05
type: execute
wave: 5
depends_on: ["19.5-04"]
gap_closure: true
files_modified:
- ../fonoteka.go/plugins/golem15/user/updates/202610210001_add_users_email_verified_at.go
- ../fonoteka.go/plugins/golem15/user/updates/organisations_test.go
- ../fonoteka.go/plugins/golem15/user/updates/email_verified_at_test.go
- ../fonoteka.go/plugins/golem15/user/models/user.go
- ../fonoteka.go/plugins/golem15/user/classes/codes.go
- ../fonoteka.go/plugins/golem15/user/controllers/api_controller.go
- ../fonoteka.go/plugins/golem15/user/controllers/users_admin_controller.go
- ../fonoteka.go/plugins/golem15/user/email_verified_test.go
- ../fonoteka.go/plugins/golem15/user/README.md
- ../sm-sociallogin-plugin/classes/flow.go
- ../sm-sociallogin-plugin/harness_test.go
- ../sm-sociallogin-plugin/owner_verified_test.go
- ../sm-sociallogin-plugin/matching_test.go
- ../sm-sociallogin-plugin/testdata/PHP_METHOD_MAP.md
- ../sm-sociallogin-plugin/README.md
- ../sm-sociallogin-plugin/go.mod
- ../sm-sociallogin-plugin/go.sum
- ../sm-bm-app/frontend/src/views/OAuthCallbackView.test.ts
- ../sm-bm-app/plugins/golem15/user
- ../sm-bm-app/plugins/golem15/sociallogin
- ../sm-bm-app/go.mod
- ../sm-bm-app/go.sum
- ../sm-demo-app/plugins/golem15/user
- ../sm-demo-app/plugins/golem15/sociallogin
- ../sm-demo-app/go.mod
- ../sm-demo-app/go.sum
- ../fonoteka.go/plugins/golem15/user
- scripts/check-phase19.5.sh
- .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/email-proof-release.md
autonomous: false
requirements: [D-26, D-27, D-28, SC-3]
estimate:
tokens: 180000
raw_tokens: 180000
tasks: 7
confidence: low
must_haves:
truths:
- "D-26: on an email match, a verified provider's identity is linked automatically only when the matched account's users.email_verified_at is set. POST /_user/api/v1/register in activate_mode=auto followed by a verified Google sign-in for the same email ends on the pending screen with reason=unverified_match and provider=google, links no identity, and its oauth-register-complete answers 422 email_exists_link_account without consuming the pending code (TestOwnerVerifiedEmail/register_auto_then_google_pending)."
- "D-26: an account whose owner activated it with the mailed code (activate-by-code or activate) or completed a mailed reset-code password reset receives the verified identity and is signed in (TestOwnerVerifiedEmail/mail_activated_by_code_links, reset_code_password_links; TestPreAccountTakeover/after_mailbox_proof_links drives the real reset routes)."
- "D-26: accounts without recorded proof take the pending path: /register in auto or admin mode, an administrator's create and Activate action, a social sign-in (even from a verifying provider), and every account that existed before sm-user-plugin v0.1.5. No migration or code path backfills the column."
- "D-27: users.email_verified_at is a nullable TIMESTAMPTZ added by migration 202610210001_add_users_email_verified_at (ADD COLUMN IF NOT EXISTS, no row written, no-op rollback). classes.VerifyActivationCode and the ResetPassword completion stamp it; /update and the admin Users form clear it when the email changes (a case-only change keeps it); it is not fillable, not in any user API payload and not in the admin form or list. No route, payload key or config key of sm-user-plugin changes."
- "D-27 wording: the READMEs of sm-user-plugin and sm-sociallogin-plugin, the PHP map row of test_verified_provider_email_still_auto_links and 19.5-SECURITY-REVIEW.md describe proof as email_verified_at (mail activation or the reset code); none of them says a self-set password proves the address."
- "D-28: src/views/OAuthCallbackView.test.ts mounts the real view in vitest and proves that router.replace drops ?code= before the single completeOAuth call (the route has no code when the call starts), that a second mount with the same code does not call completeOAuth again, that ?error= renders its message in the alert and that ?pending_registration= replaces to the oauth-register route with pending, provider, reason and email. frontend/package.json and package-lock.json are unchanged."
- "scripts/check-phase19.5.sh fails closed on the new tests (TestOwnerVerifiedEmail, TestEmailVerifiedAt, TestAdminEmailVerifiedAt, TestEmailVerifiedAtMigration, TestCreateOrganisationsMigration, src/views/OAuthCallbackView.test.ts). Its migration check accepts exactly the one added migration file and refuses any other added, modified, renamed or deleted non-test file under updates/, and a migration that writes or drops anything; --self-test proves both rules; PHASE195_REQUIRE_DOCKER=1 --all passes."
- "SC-3 and D-21 order: under the Task 6 go-ahead, sm-user-plugin v0.1.5 and then sm-sociallogin-plugin v0.1.2 (requiring sm-user-plugin v0.1.5) are pushed and tagged. sm-demo-app and sm-bm-app move plugins/golem15/user and then plugins/golem15/sociallogin in separate commits, and fonoteka.go moves plugins/golem15/user. Each application's gate stage is green at its new pins. On hold, nothing is pushed or tagged and a todo with a g15office task tracks the release."
artifacts:
- path: "../fonoteka.go/plugins/golem15/user/updates/202610210001_add_users_email_verified_at.go"
provides: "the additive users.email_verified_at column"
contains: "ADD COLUMN IF NOT EXISTS email_verified_at"
- path: "../fonoteka.go/plugins/golem15/user/models/user.go"
provides: "User.EmailVerifiedAt"
contains: "gorm:\"column:email_verified_at\" json:\"-\""
- path: "../fonoteka.go/plugins/golem15/user/email_verified_test.go"
provides: "TestEmailVerifiedAt, TestAdminEmailVerifiedAt"
contains: "TestAdminEmailVerifiedAt"
- path: "../fonoteka.go/plugins/golem15/user/updates/email_verified_at_test.go"
provides: "TestEmailVerifiedAtMigration"
contains: "TestEmailVerifiedAtMigration"
- path: "../sm-sociallogin-plugin/classes/flow.go"
provides: "ownerVerifiedEmail reads the proof column"
contains: "EmailVerifiedAt != nil"
- path: "../sm-sociallogin-plugin/owner_verified_test.go"
provides: "TestOwnerVerifiedEmail"
contains: "register_auto_then_google_pending"
- path: "../sm-bm-app/frontend/src/views/OAuthCallbackView.test.ts"
provides: "the bm callback view test (D-28)"
contains: "completeOAuth"
- path: "scripts/check-phase19.5.sh"
provides: "the fail-closed gate with the new tests and the migration allow-list"
contains: "USER_ADDED_MIGRATIONS"
key_links:
- from: "../fonoteka.go/plugins/golem15/user/classes/codes.go"
to: "users.email_verified_at"
via: "VerifyActivationCode stamps the column in the same column write that activates the account"
pattern: "email_verified_at"
- from: "../fonoteka.go/plugins/golem15/user/controllers/api_controller.go"
to: "users.email_verified_at"
via: "ResetPassword stamps it in the write that stores the new password; Update clears it on an email change"
pattern: "email_verified_at"
- from: "../sm-sociallogin-plugin/classes/flow.go"
to: "../fonoteka.go/plugins/golem15/user/models/user.go"
via: "ownerVerifiedEmail reads User.EmailVerifiedAt in HandleCallback and CompletePending"
pattern: "EmailVerifiedAt"
- from: "scripts/check-phase19.5.sh"
to: "../sm-bm-app/frontend/src/views/OAuthCallbackView.test.ts"
via: "the bm stage requires the view test file to pass by name (REQUIRE_FILES)"
pattern: "OAuthCallbackView.test.ts"
- from: "../sm-sociallogin-plugin/go.mod"
to: "sm-user-plugin v0.1.5"
via: "Task 7 requires the tag that carries the column; app pins move user first, then sociallogin"
pattern: "sm-user-plugin v0.1.5"
prohibitions:
- statement: "sm-user-plugin stays additive (core plugin, D-27): no route, user API payload key, config key, existing migration file or migration ID changes; the one new migration adds a nullable column and writes no rows."
status: unresolved
verification: judgment
- statement: "Proof is recorded only by the mailed activation code and the completed reset-code password reset. /register in auto or admin mode, RegisterUser, the admin create, the admin Activate action, change-password and every sociallogin path never write email_verified_at."
status: unresolved
verification: judgment
- statement: "No new npm dependency in sm-bm-app/frontend and no new Go module anywhere. If the view cannot be mounted with the installed packages, the plan stops at a checkpoint instead of adding one."
status: unresolved
verification: judgment
- statement: "Nothing is pushed or tagged before the Task 6 reply. Nothing is force-pushed or deployed. fonoteka.go and summercms.go are never pushed by this plan, and only plugins/golem15/user is staged in fonoteka.go."
status: unresolved
verification: judgment
---
## Phase Goal
ROADMAP Goal: `sm-user-plugin` signs users in with Google and Facebook through `/oauth/{provider}`, with the SPA one-time-code handoff and the pending-registration branch, matching the PHP user plugin's contract. Per CONTEXT D-01, the code is delivered by `sm-sociallogin-plugin`. The goal is not a user story, and plans 01-04 recorded the decision not to invent one, so this plan does not invent one either. SC-3 ("identities are linked on login ... both applications still build and pass their tests") is the success criterion this gap closure touches.
This plan (5 of 5, wave 5, `gap_closure: true`) closes the two open items of 19.5-VERIFICATION.md under the user decisions D-26, D-27 and D-28 (CONTEXT, "Decisions after verification"):
1. **Human verification item 1, the residual pre-account takeover** (19.5-REVIEW CR-01). `ownerVerifiedEmail` treats a self-set password as proof. A `/register` in `activate_mode: auto` (the default) creates an activated account with a self-set password and no mailbox check, so a later verified Google or GitHub sign-in for the same email is linked onto the account of whoever registered it. D-26 and D-27 replace the password flag with recorded proof, `users.email_verified_at`.
2. **Human verification item 2 / truth 16, the bm callback view.** `OAuthCallbackView.vue` has no test. D-28 adds a vitest view test.
Item 3 (the prohibition verdicts) stays a manual sign-off and is not touched here.
## Spec-less probe fallback
spec-less probe fallback skipped: the phase has no REQUIREMENTS.md IDs (visible skip). `must_haves` derive from D-26..D-28 and SC-3.
## Decisions taken in this plan (Claude's discretion inside D-26..D-28)
- **Proof writers.** `classes.VerifyActivationCode` stamps `email_verified_at` in the same column write that activates the account. It serves `POST /activate`, `POST /activate-by-code` and the administrator's invitation link, which mails the same activation code, so an invited user who follows the mail has proved the address. `controllers.ResetPassword` stamps it in the same write that stores the new password, so a reset that fails records nothing (D-27: "a completed reset-code password reset"). `classes.VerifyResetCode` is unchanged. The value is the time of the proof and overwrites an earlier one.
- **Not proof (D-26).**
- `/register` in `auto` or `admin` mode and `RegisterUser`: nobody read a mail.
- The admin create (`FormBeforeCreate`) and the admin Activate action (`classes.ActivateUsers`): an administrator typing or vouching for an address does not show that the user controls the mailbox.
- `ChangePassword`: it needs the current password, not the mailbox.
- Any sociallogin path, including an account created from a verifying provider: D-27 names exactly two proof sources, and the provider's claim is already the other half of the auto-link test.
- **The proof follows the address.** `/update` and the admin Users form set `email_verified_at` to NULL when the email changes. A change of letter case only (`strings.EqualFold`) keeps it, because matching is case-insensitive. Without this, an account could prove `attacker@…`, switch to the victim's address and receive the victim's verified identity: the same takeover by another route (T-19.5-40). This changes only an internal column, with no payload, route or config change, so D-27's additive rule holds.
- **Migration shape.** ID `202610210001_add_users_email_verified_at`, `ADD COLUMN IF NOT EXISTS email_verified_at TIMESTAMPTZ NULL`, rollback a no-op. This follows the `202610160002_add_handle_to_users` precedent for a shared `users` column that may already exist: a Laravel-derived schema can carry `email_verified_at`, and its values mean the same thing, an address confirmed by a mailed link. The migration writes no rows.
- **No backfill (D-26 default).** Accounts from before v0.1.5, PHP-imported data included, have no proof and take the pending path; their owners link from the settings or reset the password once. The README tells an application that trusts its imported addresses to set the column in its own data migration. Neither plugin does that.
- **Field.** `models.User.EmailVerifiedAt *time.Time` with `gorm:"column:email_verified_at" json:"-"`. It is not added to `Fillable()`, to `apiArray`, or to the admin `fields.yaml` and `columns.yaml`.
- **Matching.** sociallogin's `ownerVerifiedEmail(u)` becomes `u != nil && u.EmailVerifiedAt != nil` and no longer reads the self-set-password flag. sm-user-plugin's D-13 unlink rule still uses that flag, which is unrelated and unchanged.
- **Test fixtures.** sociallogin's harness `e.user` now creates a proven account (`EmailVerifiedAt` set, as after a mail activation), so the verified-link cases of plans 02-04 keep testing the link path. A new `e.unprovenUser` models an account without proof.
- **bm view test without a new dependency.** The bm frontend has no `@vue/test-utils`, jsdom or happy-dom (`test.environment: 'node'`). The test mounts the real SFC with `createRenderer` from `vue`, using a small in-memory host. It uses a vue-router `createMemoryHistory` router whose routes include `oauth-register`, and `vi.mock` stubs for `@/stores/auth` and `@/stores/toast`. Those stubs are required because `@/stores/auth` imports `@/router`, whose `createWebHistory` needs `window`. If that cannot work with the installed packages, the plan stops (Task 4).
- **Versions.** sm-user-plugin **v0.1.5** and sm-sociallogin-plugin **v0.1.2**. If either tag already exists on its remote, use the next free patch and record the change as a deviation. summercms.go gets no production change, so it gets no framework tag and no changelog post (the CLAUDE.md changelog rule covers framework tags, as in 19.5-04).
- **Out of scope, surfaced instead of decided.** 19.5-REVIEW CR-02 is not covered by D-26..D-28 and has no user decision. In that scenario, a pre-planted unverified identity survives the owner's reset and keeps signing in. Task 5 records it in 19.5-SECURITY-REVIEW.md as an open finding awaiting a decision, with no disposition invented.
- **Context budget and hand-offs.** The estimate is 180k across 7 tasks, above the 100k smart zone. The user confirmed one plan, so execution splits it into three contexts, as plan 04 did:
1. The first agent runs Tasks 1-3, writes `19.5-05-SUMMARY.md` as a draft headed `Status: draft (hand-off after Task 3)`, and stops. The draft lists the commits per repository with hashes, the test names and the gate stages with their state.
2. A fresh agent runs Tasks 4 and 5 and stops at the Task 6 checkpoint.
3. A continuation agent runs Task 7 after the reply and finalizes the SUMMARY.
## Source coverage audit
| Source | Item | Covered by |
|---|---|---|
| GOAL / SC-3 | Identities linked on login; consuming apps build and pass | Tasks 1, 3 (linking rule), 7 (app pins and gates) |
| CONTEXT | D-26 (proof required; auto-mode and admin-created are not proof; existing accounts pending) | Tasks 1, 2, 3 |
| CONTEXT | D-27 (additive column, two writers, v0.1.5 then v0.1.2 then pointer bumps, wording corrected) | Tasks 1, 2, 3, 5, 6, 7 |
| CONTEXT | D-28 (bm view test, no new npm dependency) | Task 4 |
| VERIFICATION | human item 1 / item 2 (truth 16) | Tasks 1-3 / Task 4 |
| REQ | none (phase has no REQUIREMENTS.md IDs) | n/a |
| RESEARCH | no new item for this follow-up | n/a |
## Artifacts this phase produces (this plan)
- **sm-user-plugin** (`../fonoteka.go/plugins/golem15/user`):
- migration file `updates/202610210001_add_users_email_verified_at.go` (variable `usersEmailVerifiedAtMigrations`, ID `202610210001_add_users_email_verified_at`) and column `users.email_verified_at TIMESTAMPTZ NULL`;
- field `models.User.EmailVerifiedAt`;
- stamping in `classes.VerifyActivationCode` and `controllers.ResetPassword`, clearing in `controllers.Update` and `usersAdminController.FormBeforeUpdate`;
- tests `TestEmailVerifiedAt` and `TestAdminEmailVerifiedAt` (root package), `TestEmailVerifiedAtMigration` (`updates`), and an updated `TestCreateOrganisationsMigration`;
- README: the Features bullet "Address proof" and a migrations row;
- tag `v0.1.5`.
- **sm-sociallogin-plugin**:
- `ownerVerifiedEmail` reads `EmailVerifiedAt`;
- harness `e.user` (proven) and `e.unprovenUser`;
- `owner_verified_test.go` with `TestOwnerVerifiedEmail`, subtests `register_auto_then_google_pending`, `mail_activated_by_code_links`, `reset_code_password_links`, `legacy_account_pending`, `register_action_pending`, `admin_activation_not_proof`, `email_change_drops_proof` and `unverified_provider_never_links_proven_account`;
- `TestPreAccountTakeover/after_mailbox_proof_links` now drives `/forgot-password` and `/reset-password`;
- the PHP map reason, the README matching table, security notes and Dependencies;
- commit `chore: require sm-user-plugin v0.1.5`; tag `v0.1.2`.
- **sm-bm-app**:
- `frontend/src/views/OAuthCallbackView.test.ts`;
- commits `chore: bump sm-user-plugin to v0.1.5` and `chore: bump sm-sociallogin-plugin to v0.1.2`.
- **sm-demo-app**: the same two bump commits.
- **fonoteka.go**: `chore: bump sm-user-plugin to v0.1.5`, staging only `plugins/golem15/user`, committed locally.
- **summercms.go**:
- `scripts/check-phase19.5.sh`:
- array `USER_ADDED_MIGRATIONS`;
- functions `refused_migration_changes` and `migration_writes_rows`;
- array `DOCKER_USER_UPDATES_TESTS` and the `REQUIRE_FILES` list in `detect_vitest`;
- the new required names and new `--self-test` cases;
- `19.5-SECURITY-REVIEW.md`: T-19.5-38 corrected, T-19.5-39..T-19.5-46 added, T-19.5-26 closed by the view test, CR-02 recorded as open;
- `19.5-VALIDATION.md`: rows `19.5-05-01`..`-07` and D-26, D-27 and D-28.
- **On "hold" only:** todo `.planning/todos/pending/email-proof-release.md` with its g15office task.
<objective>
Close the password-registration pre-account takeover by making recorded proof of the address, `users.email_verified_at`, the only owner-side condition for auto-linking a verified provider (D-26, D-27). Pin the bm OAuth callback view's ordering with a vitest view test (D-28). Extend the fail-closed gate to every new test. Then release in D-21 order under the user's go-ahead.
Purpose: VERIFICATION human items 1 and 2; SC-3's linking rule made safe under the default `activate_mode: auto`.
Output: commits in sm-user-plugin, sm-sociallogin-plugin, sm-bm-app and summercms.go (gate and planning docs). Under the Task 6 reply, also tags v0.1.5 and v0.1.2 and the pointer bumps in sm-demo-app, sm-bm-app and fonoteka.go.
</objective>
<execution_context>
@~/.claude/msd-core/workflows/execute-plan.md
@~/.claude/msd-core/templates/summary.md
</execution_context>
<context>
@.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-VERIFICATION.md
@.planning/phases/19.5-social-login-in-sm-user-plugin/19.5-04-SUMMARY.md
Repos and paths (verified with `git ls-files` at planning time):
- sm-user-plugin is the checkout at `../fonoteka.go/plugins/golem15/user` (remote `git@git.golem15.com:golem15/sm-user-plugin.git`, HEAD c7635c5, latest tag v0.1.4). It is tested through the fonoteka.go workspace.
- sm-sociallogin-plugin is at `../sm-sociallogin-plugin` (HEAD d7ee28d = v0.1.1). Its committed `go.work` replaces sm-user-plugin with `../fonoteka.go/plugins/golem15/user`, so the plugin compiles against the new field before the tag exists; its go.mod still requires v0.1.4 until Task 7.
- sm-bm-app frontend: `../sm-bm-app/frontend`; the user and sociallogin plugins are submodules at `plugins/golem15/user` and `plugins/golem15/sociallogin` (both pinned c43ba87 / d7ee28d); go.mod replaces them with the submodule paths.
- sm-demo-app: submodules at the same paths and pins; go.work replaces sm-user-plugin with `./plugins/golem15/user`; go.mod requires `sm-user-plugin v0.1.4` and `sm-sociallogin-plugin v0.1.1`, which `TestGoModRequiresTags` and `scripts/check-pins.sh` hold to tags.
- fonoteka.go: gitlink `plugins/golem15/user` at c43ba87. Its worktree already shows other sessions' modified gitlinks (`plugins/golem15/feedback`, `plugins/golem15/golem`), and `origin/master..master` holds commits that are not this phase's, so this plan commits there locally and never pushes.
Rules: no co-author tags; stage by path, never `git add -A`; one logical change per commit; planning docs and code in separate commits. Before touching a shared file (the gate script, the planning docs), check `git status` for another session's uncommitted edits and leave them alone. Docker-backed tests skip under `-short`; run them on a Docker host (through `sudo -u golem` when the socket is denied). Keep `go vet` and `go test -short` green in every repository at every commit.
<interfaces>
- sm-user-plugin:
- `models.User` (models/user.go): fields `HasSelfSetPassword bool`, `ActivatedAt *time.Time`, `LastSeen *time.Time` (`json:"-"` precedent); `Fillable()` excludes timestamps.
- `classes.VerifyActivationCode(ctx, db, userID, code) (*models.User, bool)` (classes/codes.go:117): the `UpdateColumns` map with `is_activated`, `activated_at`, `activation_code`, `activation_code_issued_at`.
- `classes.VerifyResetCode` (codes.go:94) sets `has_self_set_password` and clears the code.
- `controllers.ResetPassword` (api_controller.go:290): `Updates(map{password, tokens_valid_after})` after `VerifyResetCode`.
- `controllers.Update` (api_controller.go:439): `Updates(map{name, surname, email})` on the authenticated user.
- `controllers.Register` (:634) with `registrationSettings` mode auto|user|admin.
- `usersAdminController.FormBeforeUpdate` (users_admin_controller.go:215) and `guardCredentials` (:468), which already plucks the stored email; cabana writes the record with `tx.Unscoped().Save(target)` (summercms.go `modules/cabana/crud.go:830`), so a field set on the model in `FormBeforeUpdate` is persisted.
- `classes.ActivateUsers` (admin_actions.go:64).
- Migrations self-register through `updates.Register` in `init()`, ordered by file name; `TestCreateOrganisationsMigration` (updates/organisations_test.go) asserts the exact history list and rolls back 11 steps.
- Test helpers: `sessionApp`, `insertUser`, `loginToken`, `postJSON`, `bearer` (session_test.go), `bearerJSON`, `captureMail`; `newAdminEnv`, `env.admin`, `env.seedUser`, `env.reload`, `env.bulk`, `env.mails` (admin_harness_test.go); `dedicatedDB` (updates/postgres_test.go); `TestAdminUserInvite` (admin_users_test.go:1105) shows the invitation flow.
- sm-sociallogin-plugin:
- `ownerVerifiedEmail` (classes/flow.go:262), used in `HandleCallback` (:98) and `CompletePending` (:203).
- Harness: `bootSocial`, `bootSocialWith`, `e.user` (harness_test.go:176), `e.credentials`, `e.setConfig`, `e.identityOwner`, `e.complete`, `e.do`, `e.browser` (harness_test.go); `e.flow`, `registerQuery`, `spaQuery`, `freshIP`, `fromIP`, `e.postRegisterComplete` (unit_helpers_test.go); `gothFakeRegistry(t, apis)` and `e.flowAt` (php_port_test.go:38-75); `newFakeAPIs`, `fakeToken` (providers_goth_test.go); `e.putPending`, `e.pendingExists` (register_complete_test.go); `e.userCount` (registration_test.go).
- `TestPreAccountTakeover` (matching_test.go:561).
- The Google userinfo route `www.googleapis.com/oauth2/v2/userinfo` with `verified_email`.
- The en message for a refused completion: "An account with this email already exists. Please sign in and link your Google account from settings." (label substituted).
- bm frontend:
- `src/views/OAuthCallbackView.vue`: a module-level `redeemed` Set; `onMounted` replaces to `oauth-register` for pending, else `router.replace({ path })` then `auth.completeOAuth(code)`.
- `src/lib/oauth.ts` `parseOAuthCallback`; `src/stores/auth.ts` imports `@/router`; `src/router.ts` uses `createWebHistory`.
- `vite.config.ts` `test: { environment: 'node', include: ['src/**/*.test.ts'] }`; tsconfig includes `src/**/*.ts` (tests are typechecked, `noUnusedLocals`).
- package.json devDependencies: `@vitejs/plugin-vue`, `typescript`, `vite`, `vitest`, `vue-tsc` only.
- Gate: `scripts/check-phase19.5.sh` (`run_userplugin` migration check at :467-478, `detect_vitest` at :215 with `REQUIRE_FILE`, `run_bm` at :521, `run_docker` at :570, `run_self_test` at :349).
</interfaces>
</context>
<tasks>
<task type="tracer">
<name>Task 1: Tracer: users.email_verified_at end to end: mail activation and the reset code stamp it, sociallogin links a verified email only onto a proven account, and an auto-mode /register account goes pending</name>
<files>../fonoteka.go/plugins/golem15/user/updates/202610210001_add_users_email_verified_at.go, ../fonoteka.go/plugins/golem15/user/updates/organisations_test.go, ../fonoteka.go/plugins/golem15/user/models/user.go, ../fonoteka.go/plugins/golem15/user/classes/codes.go, ../fonoteka.go/plugins/golem15/user/controllers/api_controller.go, ../fonoteka.go/plugins/golem15/user/README.md, ../sm-sociallogin-plugin/classes/flow.go, ../sm-sociallogin-plugin/harness_test.go, ../sm-sociallogin-plugin/owner_verified_test.go, ../sm-sociallogin-plugin/matching_test.go, ../sm-sociallogin-plugin/README.md, scripts/check-phase19.5.sh</files>
<read_first>../fonoteka.go/plugins/golem15/user/updates/202610160002_add_handle_to_users.go, ../fonoteka.go/plugins/golem15/user/updates/registry.go, ../fonoteka.go/plugins/golem15/user/updates/organisations_test.go, ../fonoteka.go/plugins/golem15/user/models/user.go, ../fonoteka.go/plugins/golem15/user/classes/codes.go, ../fonoteka.go/plugins/golem15/user/controllers/api_controller.go (ResetPassword :290-342), ../fonoteka.go/plugins/golem15/user/README.md (Features, migrations tables :141-160), ../sm-sociallogin-plugin/classes/flow.go (:55-120, :190-265), ../sm-sociallogin-plugin/harness_test.go, ../sm-sociallogin-plugin/unit_helpers_test.go, ../sm-sociallogin-plugin/php_port_test.go (:30-75), ../sm-sociallogin-plugin/matching_test.go (:555-640), ../sm-sociallogin-plugin/README.md (:40-60, :138-155), scripts/check-phase19.5.sh, 19.5-CONTEXT.md (D-26, D-27), 19.5-REVIEW.md (CR-01)</read_first>
<action>The thinnest path through every layer, per D-26 and D-27: the column, two writers, the reader in the matching, one end-to-end test, and the gate.
sm-user-plugin:
- New migration file `updates/202610210001_add_users_email_verified_at.go`, shaped like `202610160002_add_handle_to_users.go`:
- variable `usersEmailVerifiedAtMigrations`, ID `202610210001_add_users_email_verified_at`;
- Migrate runs `ALTER TABLE users ADD COLUMN IF NOT EXISTS email_verified_at TIMESTAMPTZ NULL` and nothing else;
- Rollback is a no-op, with the handle file's reason (a shared users column that may predate the plugin);
- registered in `init()`;
- the doc comment says the column records when the owner proved the address (D-27), that the migration writes no rows (no backfill, D-26) and that an existing column keeps its values.
- `models.User` gains `EmailVerifiedAt *time.Time` with tag `gorm:"column:email_verified_at" json:"-"` and a doc comment naming its writers. Do not add it to `Fillable()`.
- `classes.VerifyActivationCode`: add `email_verified_at: now` to the existing `UpdateColumns` map and set `user.EmailVerifiedAt` (one write: activation and proof together). Update its doc comment.
- `controllers.ResetPassword`: add `email_verified_at: now` to the `Updates` map that stores the new password and `tokens_valid_after`, so the proof exists only when the reset completed.
- `updates/organisations_test.go`: append the new ID to the expected history and roll back 12 steps instead of 11, updating the comment.
- README:
- a Features bullet "Address proof": the column, its two writers, that /register in auto or admin mode, the admin create and Activate action and social sign-ins record none, and no backfill;
- a table "Migrations added for address proof, all additive" with the row for `202610210001_add_users_email_verified_at` (`TIMESTAMPTZ` NULL, `ADD COLUMN IF NOT EXISTS`, no backfill, rollback a no-op).
- One commit: `feat: users.email_verified_at records a proved address (mail activation, reset code)`.
sm-sociallogin-plugin:
- `ownerVerifiedEmail` returns `u != nil && u.EmailVerifiedAt != nil`. Rewrite its doc comment: proof means a mailed activation code or a completed reset-code password reset (D-26); a self-set password alone is not proof (`/register` in auto mode, admin-created); accounts without proof go to the pending screen and their owner links from the settings. Keep the line in `HandleCallback`'s doc comment that names `ownerVerifiedEmail`.
- Harness:
- `e.user` sets `EmailVerifiedAt` to now and its doc comment says the account is proven (as after a mail activation), so the verified-link cases of plans 02-04 keep testing the link path;
- add `e.unprovenUser(t, email)`: activated, self-set password, no proof.
- New `owner_verified_test.go` with helpers on `*socialEnv`, each going through the user plugin's real routes on the test server with `fromIP(nil, freshIP())`:
- `registerWithPassword` posts JSON to `/_user/api/v1/register` (email, password, password_confirmation);
- `activateByMail` reads `activation_code` from the users row and posts `{id}!{code}` to `/_user/api/v1/activate-by-code`;
- `resetByMail` posts `/_user/api/v1/forgot-password`, reads `reset_password_code` and posts `/_user/api/v1/reset-password`;
- a Google sign-in helper through `gothFakeRegistry` (published in `bootSocialWith`), `e.credentials(t, "google")`, the token route `oauth2.googleapis.com/token` and a userinfo body with `verified_email` true, driven with `e.flowAt`.
- `TestOwnerVerifiedEmail` has three subtests:
- `register_auto_then_google_pending`:
- with `golem15.user.activation.activate_mode` set to `auto` explicitly, `/register` answers 200 with a token, and the row has `is_activated` true and `email_verified_at` NULL;
- a verified Google sign-in for the same email (upper-cased to prove the case-insensitive match) redirects with `pending_registration`, `reason=unverified_match`, `provider=google` and no `code`, and no google identity exists;
- posting that code to `oauth-register-complete` answers 422 with the en `email_exists_link_account` message for Google, and `e.pendingExists` stays true;
- `action=register` with consent for the same email also goes pending with no new users row (`e.userCount` 1).
- `mail_activated_by_code_links`: with `activate_mode` user, `/register` answers "Activation email sent"; `activateByMail` sets the column; the verified Google sign-in redirects with `code`, the identity owner is that user, and `e.complete` returns that user's id.
- `reset_code_password_links`: an auto-mode `/register` account, then `resetByMail`, then the verified Google sign-in links and signs in.
- `TestPreAccountTakeover`:
- its setup also requires the squatted account's `EmailVerifiedAt` to be nil;
- `after_mailbox_proof_links` no longer writes the self-set-password column directly; it calls `resetByMail` for the squatted account, then asserts the link;
- its doc comment says the owner proves the address with the mailed reset (`email_verified_at`).
- README:
- the "Account matching" rows: a match links only when the provider verified the email and the account's owner proved the address (`users.email_verified_at`: mail activation or a password set through the mailed reset code);
- an account without proof (`/register` with `activate_mode: auto`, created by an administrator or by a social sign-in, or from before sm-user-plugin v0.1.5) goes pending with `unverified_match` even for a verified email, completion 422; the owner links from the settings or proves the address with a password reset;
- the "Pre-account takeover" security note now explains this;
- Dependencies name golem15.user v0.1.5 or newer (`users.email_verified_at`).
- One commit: `fix: a verified sign-in links only onto an account that proved its address`.
Gate (`scripts/check-phase19.5.sh`):
- Add `USER_ADDED_MIGRATIONS=(updates/202610210001_add_users_email_verified_at.go)` with a comment (D-27 relaxes D-22 for this one additive migration).
- Add `refused_migration_changes`: it reads `git diff --name-status` lines and prints every line whose status is not `A` or whose path is not in `USER_ADDED_MIGRATIONS`.
- Add `migration_writes_rows`: it reads a Go file, drops comment lines (`^\s*//`), and prints any line matching the uppercase SQL words that write or drop data.
- In `run_userplugin`:
- use `git diff --name-status "$USER_BASE" -- updates/ ':(exclude)*_test.go'`, keeping the existing capture of git's exit status;
- refuse what `refused_migration_changes` prints, with the message "(D-22, D-27: only the email_verified_at migration may be added)";
- for each allowed file present, require `ADD COLUMN IF NOT EXISTS email_verified_at` and refuse anything `migration_writes_rows` prints;
- update the header comment of the userplugin stage.
- `--self-test` cases:
- accepted: an `A` line of the allowed file;
- refused: an `M` line of `updates/00_base.go`; an `A` line of another file; a `D` line; an `R100` rename;
- `migration_writes_rows` refuses a planted Exec of a row update and accepts a planted file whose only such word is in a `//` comment.
- Append `TestOwnerVerifiedEmail` to `DOCKER_SOCIAL_TESTS` under a "Plan 05 (D-26)" comment.
- Commit: `test(19.5): gate the email_verified_at migration and TestOwnerVerifiedEmail`.</action>
<verify>
<automated>go -C ../sm-sociallogin-plugin test -count=1 -run '^(TestOwnerVerifiedEmail|TestPreAccountTakeover)$' -v . 2&gt;&amp;1 | grep -c -- '^--- PASS: Test' | grep -qx 2</automated>
<fails_when>The count of top-level "--- PASS: Test" lines is not 2: the auto-mode account was linked, a proven account was not linked, a subtest failed or skipped (Docker unreachable: rerun through sudo -u golem), or the package does not compile against the workspace user plugin.</fails_when>
<automated>bash scripts/check-phase19.5.sh --self-test &amp;&amp; bash scripts/check-phase19.5.sh --userplugin &amp;&amp; bash scripts/check-phase19.5.sh --sociallogin</automated>
<fails_when>Non-zero exit: a planted migration case is not caught or the allowed file is refused, the user plugin's vet or short tests fail, a removed export is found, a non-allowed file under updates/ changed, the new migration writes rows or lacks the ADD COLUMN, or a sociallogin Docker-free test fails.</fails_when>
<automated>bash scripts/check-phase19.5.sh --docker</automated>
<fails_when>Non-zero exit: an existing Postgres test of the user plugin or sociallogin (for example TestMatching, TestFlowSmoke, TestPHPPendingRegistrationTakeover) no longer passes with the proven-fixture semantics, TestOwnerVerifiedEmail is missing or skipped, or no Docker daemon answered (rerun through sudo -u golem; otherwise record NOT RUN).</fails_when>
</verify>
<acceptance_criteria>
- `grep -c 'ADD COLUMN IF NOT EXISTS email_verified_at' ../fonoteka.go/plugins/golem15/user/updates/202610210001_add_users_email_verified_at.go` prints 1, and `grep -v '^\s*//' ../fonoteka.go/plugins/golem15/user/updates/202610210001_add_users_email_verified_at.go | grep -cE '\b(UPDATE|INSERT|DELETE|DROP)\b'` prints 0.
- `grep -c 'gorm:"column:email_verified_at" json:"-"' ../fonoteka.go/plugins/golem15/user/models/user.go` prints 1.
- `grep -c 'email_verified_at' ../fonoteka.go/plugins/golem15/user/classes/codes.go` prints at least 1 and `grep -c 'email_verified_at' ../fonoteka.go/plugins/golem15/user/controllers/api_controller.go` prints at least 1.
- `grep -c '202610210001_add_users_email_verified_at' ../fonoteka.go/plugins/golem15/user/updates/organisations_test.go` prints 1.
- `grep -c 'EmailVerifiedAt != nil' ../sm-sociallogin-plugin/classes/flow.go` prints at least 1 and `grep -c 'HasSelfSetPassword' ../sm-sociallogin-plugin/classes/flow.go` prints 0.
- `grep -cE 't.Run\("(register_auto_then_google_pending|mail_activated_by_code_links|reset_code_password_links)"' ../sm-sociallogin-plugin/owner_verified_test.go` prints 3.
- `grep -c 'and the account has a self-set password' ../sm-sociallogin-plugin/README.md` prints 0 and `grep -c 'email_verified_at' ../sm-sociallogin-plugin/README.md` prints at least 2.
- `grep -c 'USER_ADDED_MIGRATIONS' scripts/check-phase19.5.sh` prints at least 2 and `grep -c 'TestOwnerVerifiedEmail' scripts/check-phase19.5.sh` prints at least 1.
</acceptance_criteria>
<done>A password-registered account without mailbox proof no longer receives a verified provider's identity, a mail-activated or reset account does, and the gate runs the end-to-end test by name.</done>
<reversibility rating="costly">A published migration in a shared core plugin: once v0.1.5 runs in an application, the column exists there for good (the rollback is a no-op on purpose). The user approved the column in D-27.</reversibility>
</task>
<task type="auto" tdd="true">
<name>Task 2: sm-user-plugin: the proof follows the address (an email change through /update or the admin form clears it), only the two mailed flows write it, and the migration writes no rows: TestEmailVerifiedAt, TestAdminEmailVerifiedAt, TestEmailVerifiedAtMigration</name>
<files>../fonoteka.go/plugins/golem15/user/controllers/api_controller.go, ../fonoteka.go/plugins/golem15/user/controllers/users_admin_controller.go, ../fonoteka.go/plugins/golem15/user/email_verified_test.go, ../fonoteka.go/plugins/golem15/user/updates/email_verified_at_test.go, ../fonoteka.go/plugins/golem15/user/README.md, scripts/check-phase19.5.sh</files>
<read_first>../fonoteka.go/plugins/golem15/user/controllers/api_controller.go (Update :439-486, Register :634-700, ChangePassword :492-588, apiArray :829), ../fonoteka.go/plugins/golem15/user/controllers/users_admin_controller.go (FormBeforeUpdate :215-237, FormBeforeCreate :243, guardCredentials :468-496, FormAfterCreate invite :305-350), ../fonoteka.go/plugins/golem15/user/classes/admin_actions.go (ActivateUsers :64), ../fonoteka.go/plugins/golem15/user/account_test.go, ../fonoteka.go/plugins/golem15/user/register_test.go, ../fonoteka.go/plugins/golem15/user/session_test.go (helpers :359-540), ../fonoteka.go/plugins/golem15/user/admin_harness_test.go, ../fonoteka.go/plugins/golem15/user/admin_users_test.go (TestAdminUserInvite :1105, TestAdminUserActions :575), ../fonoteka.go/plugins/golem15/user/updates/postgres_test.go, ../fonoteka.go/plugins/golem15/user/updates/admin_columns_test.go, ../fonoteka.go/plugins/golem15/user/README.md, scripts/check-phase19.5.sh</read_first>
<behavior>
- /register in auto mode and in admin mode leaves email_verified_at NULL; a submitted email_verified_at field is ignored (not fillable) on /register and on /update.
- activate-by-code with the right code stamps it; /activate with the right code stamps it and with a wrong code leaves NULL; the response bodies carry no email_verified_at key.
- reset-password with a valid code stamps it; a wrong or malformed code answers 422 and leaves NULL; change-password never stamps it.
- /update with a different email clears a stamped proof; the same email in another letter case keeps it; the response body carries no email_verified_at key.
- Admin: a user created on the Users form has NULL; the activate bulk action activates but leaves NULL; an invitation followed through activate-by-code stamps it; a form update that changes the email clears it; a form update that keeps the email keeps it; the admin form and list JSON carry no email_verified_at key.
- Migration: the column is TIMESTAMPTZ and nullable; rows that exist before it runs stay NULL; a pre-existing column keeps its values; rollback leaves the column in place; the ID appears once in updates.All().
</behavior>
<action>Production changes, in one commit: `fix: an email change drops the address proof`.
- `controllers.Update`: when the submitted email differs from the authenticated user's current email under `strings.EqualFold`, add `email_verified_at: nil` to the same `Updates` map and clear `user.EmailVerifiedAt`. Otherwise leave the column alone. The user payload is unchanged.
- `usersAdminController.FormBeforeUpdate`: after the existing guards, when the stored email (the value `guardCredentials` already reads; an unexported helper may return it) differs from the submitted one under `strings.EqualFold`, set `user.EmailVerifiedAt` to nil. cabana's `Save` persists it.
- Write the proof nowhere else (D-26): `Register`, `RegisterUser`, `FormBeforeCreate`, `classes.ActivateUsers` and `ChangePassword` stay as they are.
- README: extend the "Address proof" bullet. An email change through /update or the admin form clears the proof. The column is never mass-assigned and never in a payload. An application that trusts its imported addresses may set the column in its own data migration; this plugin does not.
Tests, in one commit: `test: email_verified_at writers, non-writers and the migration`.
- Root package `email_verified_test.go` (Postgres, skips under `-short`):
- `TestEmailVerifiedAt` covers the API cases of the behavior block, one subtest each, through `sessionApp`, `insertUser`, `loginToken`, `postJSON`, `bearerJSON`, the controller handlers, `classes.IssueActivationCode` and `classes.IssueResetCode`. Subtest names: `register_auto_no_proof`, `register_admin_mode_no_proof`, `register_ignores_submitted_proof`, `activate_by_code_stamps`, `activate_stamps`, `activate_wrong_code_no_proof`, `reset_password_stamps`, `reset_wrong_code_no_proof`, `change_password_no_proof`, `update_email_change_clears`, `update_same_email_keeps`, `update_ignores_submitted_proof`. Each stamp is asserted to lie between the times taken before and after the request.
- `TestAdminEmailVerifiedAt` covers the admin cases through `newAdminEnv`, `env.admin(t, permAccessUsers)`, the form create and update calls, `env.bulk` with the activate action and the invitation mail as in `TestAdminUserInvite`. Subtest names: `admin_create_no_proof`, `admin_activate_action_no_proof`, `admin_invite_link_stamps`, `admin_email_change_clears`, `admin_save_keeps_proof`, `admin_json_hides_proof`.
- `updates/email_verified_at_test.go`: `TestEmailVerifiedAtMigration` on `dedicatedDB`, with subtests:
- `column_shape`: information_schema `data_type` is `timestamp with time zone` and `is_nullable` is `YES`;
- `no_backfill`: gormigrate `MigrateTo("202610160002_add_handle_to_users")`, insert activated users with self-set passwords, then `Migrate()`; every row has NULL;
- `existing_column_kept`: on a fresh database, add the column by hand with a value before the migration runs; afterwards the migration ran and the value is unchanged;
- `rollback_keeps_column`: `RollbackMigration(usersEmailVerifiedAtMigrations[0])` leaves the column;
- `history_once`.
Gate, in one commit: `test(19.5): gate the user-plugin proof tests`.
- Append `TestEmailVerifiedAt` and `TestAdminEmailVerifiedAt` to `DOCKER_USER_ROOT_TESTS` under a "Plan 05 (D-26, D-27)" comment.
- Add `DOCKER_USER_UPDATES_TESTS=(TestEmailVerifiedAtMigration TestCreateOrganisationsMigration)` and run it in `run_docker` with `go_named "$FONOTEKA" ./plugins/golem15/user/updates "-timeout 20m"`.
- Update the docker stage's header comment.</action>
<verify>
<automated>go -C ../fonoteka.go test -count=1 -run '^(TestEmailVerifiedAt|TestAdminEmailVerifiedAt)$' -v ./plugins/golem15/user 2&gt;&amp;1 | grep -c -- '^--- PASS: Test' | grep -qx 2</automated>
<fails_when>The count of top-level "--- PASS: Test" lines is not 2: a writer or non-writer behaves differently from the behavior block, an email change keeps the proof, a payload gained a key, or the tests skipped (Docker unreachable: rerun through sudo -u golem).</fails_when>
<automated>go -C ../fonoteka.go test -count=1 -run '^(TestEmailVerifiedAtMigration|TestCreateOrganisationsMigration)$' -v ./plugins/golem15/user/updates 2&gt;&amp;1 | grep -c -- '^--- PASS: Test' | grep -qx 2</automated>
<fails_when>The count is not 2: the column has the wrong type or nullability, existing rows were stamped, a pre-existing column lost its value, the rollback removed the column, or the history list is wrong.</fails_when>
<automated>bash scripts/check-phase19.5.sh --userplugin &amp;&amp; bash scripts/check-phase19.5.sh --fonoteka &amp;&amp; bash scripts/check-phase19.5.sh --docker</automated>
<fails_when>Non-zero exit: the user plugin's vet or short suite, the additive and migration checks, the fonoteka.go short suite at the changed user plugin, or a required Docker test (including the new names) fails, skips or is missing.</fails_when>
</verify>
<acceptance_criteria>
- `grep -c 'EqualFold' ../fonoteka.go/plugins/golem15/user/controllers/api_controller.go` prints at least 1 and `grep -c 'EmailVerifiedAt' ../fonoteka.go/plugins/golem15/user/controllers/users_admin_controller.go` prints at least 1.
- `grep -c 'func TestEmailVerifiedAt(' ../fonoteka.go/plugins/golem15/user/email_verified_test.go` prints 1 and `grep -c 'func TestAdminEmailVerifiedAt(' ../fonoteka.go/plugins/golem15/user/email_verified_test.go` prints 1.
- `grep -cE 't.Run\("(update_email_change_clears|admin_email_change_clears|admin_activate_action_no_proof|register_auto_no_proof|reset_password_stamps|activate_by_code_stamps)"' ../fonoteka.go/plugins/golem15/user/email_verified_test.go` prints 6.
- `grep -c 'func TestEmailVerifiedAtMigration(' ../fonoteka.go/plugins/golem15/user/updates/email_verified_at_test.go` prints 1 and `grep -c 't.Run("no_backfill"' ../fonoteka.go/plugins/golem15/user/updates/email_verified_at_test.go` prints 1.
- `grep -c 'DOCKER_USER_UPDATES_TESTS' scripts/check-phase19.5.sh` prints at least 2 and `grep -cE 'TestEmailVerifiedAt|TestAdminEmailVerifiedAt' scripts/check-phase19.5.sh` prints at least 2.
- `git -C ../fonoteka.go/plugins/golem15/user diff --name-only v0.1.4 -- routes.go config/ lang/ models/user/` prints nothing (no route, config key, phrase or admin YAML changed).
</acceptance_criteria>
<done>The proof column has exactly two writers, follows the address it proves, never leaks into a payload, and its migration is additive with no backfill. The gate runs every case by name.</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: sm-sociallogin-plugin: the full owner-proof table end to end (legacy, register action, admin activation, email change, unverified provider), with the PHP map row and README wording corrected</name>
<files>../sm-sociallogin-plugin/owner_verified_test.go, ../sm-sociallogin-plugin/testdata/PHP_METHOD_MAP.md, ../sm-sociallogin-plugin/README.md</files>
<read_first>../sm-sociallogin-plugin/owner_verified_test.go (Task 1), ../sm-sociallogin-plugin/harness_test.go, ../sm-sociallogin-plugin/unit_helpers_test.go, ../sm-sociallogin-plugin/php_port_test.go (:120-160, :340-360), ../sm-sociallogin-plugin/testdata/PHP_METHOD_MAP.md (row test_verified_provider_email_still_auto_links), ../sm-sociallogin-plugin/php_method_map_test.go, ../sm-sociallogin-plugin/README.md, ../fonoteka.go/plugins/golem15/user/classes/admin_actions.go (ActivateUsers), ../fonoteka.go/plugins/golem15/user/controllers/api_controller.go (Update)</read_first>
<behavior>
- legacy_account_pending: an account made with e.unprovenUser (self-set password, no proof: an account from before v0.1.5) gets pending unverified_match from a verified Google sign-in; no identity; completion 422 keeps the code.
- register_action_pending: action=register with consent for an unproven account's email goes pending unverified_match and creates no second users row.
- admin_activation_not_proof: an account registered in activate_mode admin and activated through userclasses.ActivateUsers stays without proof and goes pending.
- email_change_drops_proof: a proven account (activated by mail) changes its email to the victim's through POST /_user/api/v1/update with its bearer token; the row's proof is NULL; a verified Google sign-in for the victim's email goes pending and links nothing.
- unverified_provider_never_links_proven_account: a Facebook sign-in (never verified, D-11) whose email matches a proven account goes pending unverified_match.
</behavior>
<action>Extend `TestOwnerVerifiedEmail` with the five subtests of the behavior block (D-26), using the Task 1 helpers. The Facebook case uses `gothFakeRegistry` with the Graph routes as in `TestPHPSocialAuthGate`. Every pending case also asserts that `e.identityOwner` is 0 for the provider id and that the redirect carries no `code`. One commit in the plugin: `test: owner proof table for verified sign-ins`.
`testdata/PHP_METHOD_MAP.md`, row `test_verified_provider_email_still_auto_links`: keep it `adapted` and rewrite its reason. The account in the port is proven (its email_verified_at is set, as after a mail activation). Go auto-links a verified email only onto an account whose owner proved the address (`users.email_verified_at`: mail activation or the reset code, D-26), a deliberate divergence from PHP against pre-account takeover (`TestPreAccountTakeover`, `TestOwnerVerifiedEmail`).
README:
- In the "Account matching" section, add a sentence that an email change through golem15.user's /update or admin form drops the proof.
- In "Security notes", the "Pre-account takeover" bullet covers both variants: an account created from an unverified provider email, and an account registered with a password but never proved. It says why a self-set password is not proof under `activate_mode: auto`.
- One commit with the map: `docs: proof of address in the matching table and the PHP map`.
`TestPHPMethodMap` must stay green.
Hand-off point: after these commits, write the `19.5-05-SUMMARY.md` draft described under "Decisions taken in this plan" (context budget). This context ends here; a fresh agent runs Tasks 4 and 5.</action>
<verify>
<automated>go -C ../sm-sociallogin-plugin test -count=1 -run '^(TestOwnerVerifiedEmail|TestPreAccountTakeover|TestPHPPendingRegistrationTakeover)$' -v . 2&gt;&amp;1 | grep -c -- '^--- PASS: Test' | grep -qx 3</automated>
<fails_when>The count of top-level "--- PASS: Test" lines is not 3: an unproven, admin-activated or email-changed account was linked, a proven account stopped linking, a subtest skipped (Docker unreachable: rerun through sudo -u golem), or the PHP port no longer passes.</fails_when>
<automated>bash scripts/check-phase19.5.sh --sociallogin &amp;&amp; bash scripts/check-phase19.5.sh --docker</automated>
<fails_when>Non-zero exit: TestPHPMethodMap fails on the edited map, a Docker-free plugin test fails, or a required Postgres test of any repository fails, skips or is missing.</fails_when>
</verify>
<acceptance_criteria>
- `grep -cE 't.Run\("(legacy_account_pending|register_action_pending|admin_activation_not_proof|email_change_drops_proof|unverified_provider_never_links_proven_account)"' ../sm-sociallogin-plugin/owner_verified_test.go` prints 5.
- `grep 'test_verified_provider_email_still_auto_links' ../sm-sociallogin-plugin/testdata/PHP_METHOD_MAP.md | grep -c 'email_verified_at'` prints 1.
- `grep -c 'activate_mode: auto' ../sm-sociallogin-plugin/README.md` prints at least 1.
- `test -f .planning/phases/19.5-social-login-in-sm-user-plugin/19.5-05-SUMMARY.md` exits 0 and `grep -c 'Status: draft (hand-off after Task 3)' .planning/phases/19.5-social-login-in-sm-user-plugin/19.5-05-SUMMARY.md` prints 1 (until Task 7 finalizes it).
</acceptance_criteria>
<done>Every way an account can reach an email match without proof ends on the pending screen, and every mailed proof links. The README and the PHP map say so.</done>
</task>
<task type="auto">
<name>Task 4: bm: vitest view test for OAuthCallbackView (code out of the URL before one redeem, no second redeem, error message, pending route) with no new npm dependency, required by name in the gate</name>
<files>../sm-bm-app/frontend/src/views/OAuthCallbackView.test.ts, scripts/check-phase19.5.sh</files>
<read_first>19.5-05-SUMMARY.md draft (hand-off from Tasks 1-3), ../sm-bm-app/frontend/src/views/OAuthCallbackView.vue, ../sm-bm-app/frontend/src/lib/oauth.ts, ../sm-bm-app/frontend/src/lib/oauth.test.ts, ../sm-bm-app/frontend/src/stores/auth.ts, ../sm-bm-app/frontend/src/stores/toast.ts, ../sm-bm-app/frontend/src/router.ts (:1-20), ../sm-bm-app/frontend/src/api/http.ts (ApiError), ../sm-bm-app/frontend/src/api/http.test.ts (test style), ../sm-bm-app/frontend/vite.config.ts, ../sm-bm-app/frontend/tsconfig.json, ../sm-bm-app/frontend/package.json, scripts/check-phase19.5.sh (detect_vitest :213-232, run_bm :521-551, self-test :374-383)</read_first>
<action>D-28. Create `src/views/OAuthCallbackView.test.ts` in `../sm-bm-app/frontend`, with no new dependency.
- Stubs: `vi.mock('@/stores/auth')` returns a stub whose `completeOAuth` is a `vi.fn` and whose `loggedIn` is false. `vi.mock('@/stores/toast')` returns a stub with `show` as a `vi.fn`. This is needed because the real auth store imports `@/router`, which calls `createWebHistory` and needs `window`.
- Mounting: mount the real `OAuthCallbackView.vue` with an app from `createRenderer` (exported by `vue`), over a minimal in-memory host defined in the test file: element and text nodes, `insert`, `remove`, `createElement`, `createText`, `createComment`, `setText`, `setElementText`, `parentNode`, `nextSibling`, and a `patchProp` that stores attributes. Use a router from `createRouter` with `createMemoryHistory` and the routes `/` (name dashboard), `/auth/oauth/callback` (this view), `/auth/oauth/rejestracja` (name `oauth-register`, a stub component), `/profil` and `/logowanie`. Push `/` first, then the callback URL, await `router.isReady()`, mount, and flush pending promises until the view settles.
- A text helper reads the element whose `role` is `alert`.
- Cases, one `it` each, each with a distinct code because the view's `redeemed` set lives at module level:
1. `replaces the route before the single redeem`: `router.replace` (a `vi.spyOn`) is invoked before `completeOAuth` (compare `mock.invocationCallOrder`); inside the `completeOAuth` stub, `router.currentRoute.value.query.code` is undefined; `completeOAuth` is called exactly once with the code; afterwards `router.back()` lands on `/`, not on a URL with `code`.
2. `does not redeem a code twice`: unmount and mount again on the same callback URL with the same code; `completeOAuth` is still called once in total.
3. `shows the error message`: `?error=Test%20message` renders `Test message` in the alert, and `completeOAuth` is not called.
4. `routes a pending registration`: `?pending_registration=p1&provider=microsoft&reason=unverified_match&email=a%40b.test` ends on the route named `oauth-register` with query pending, provider, reason and email, through `replace` (Back does not return to the callback); `completeOAuth` is not called.
5. `explains an expired code`: `completeOAuth` rejects with `new ApiError(410, 'gone')`, and the alert reads `Link logowania wygasł lub został już użyty.`
- The file must pass `npm run typecheck` (strict, `noUnusedLocals`).
- If mounting turns out to be impossible with the installed packages (for example the SFC cannot run under the custom renderer in the node environment): do not add a dependency and do not change `vite.config.ts`'s environment. Stop and return `## CHECKPOINT REACHED` with the error and the dependency that would be needed, as the user asked.
- Commit in sm-bm-app (stage only the test file): `test(frontend): OAuth callback view drops the code before one redeem`.
Gate:
- `detect_vitest` takes `REQUIRE_FILES` (space-separated), and every listed file must appear as a passing `✓ <file> (N tests)` line. Keep reading `REQUIRE_FILE` as a one-entry fallback.
- `run_bm` passes `src/lib/oauth.test.ts src/views/OAuthCallbackView.test.ts`.
- The self-test adapts the five existing vitest cases and adds `missing-view-file`: output with only oauth.test.ts passing while both are required gives exit 5.
- Update the bm stage's header comment.
- Commit: `test(19.5): gate the bm callback view test`.</action>
<verify>
<automated>npm --prefix ../sm-bm-app/frontend run test -- src/views/OAuthCallbackView.test.ts 2&gt;&amp;1 | grep -qE '✓ src/views/OAuthCallbackView.test.ts \([5-9] tests?\)' &amp;&amp; npm --prefix ../sm-bm-app/frontend run typecheck</automated>
<fails_when>No passing line for the view test file with at least 5 tests (a case failed, the view could not mount, or the file is missing), or vue-tsc reports an error in the test file.</fails_when>
<automated>git -C ../sm-bm-app diff --quiet HEAD -- frontend/package.json frontend/package-lock.json &amp;&amp; bash scripts/check-phase19.5.sh --self-test &amp;&amp; bash scripts/check-phase19.5.sh --bm</automated>
<fails_when>Non-zero exit: package.json or the lockfile changed (a new dependency), the vitest detector does not refuse the planted missing-view-file output, or the bm stage (Go tests, the whole vitest suite with both required files, typecheck) fails.</fails_when>
</verify>
<acceptance_criteria>
- `test -f ../sm-bm-app/frontend/src/views/OAuthCallbackView.test.ts` exits 0 and `grep -c 'createRenderer' ../sm-bm-app/frontend/src/views/OAuthCallbackView.test.ts` prints at least 1.
- `grep -c 'createMemoryHistory' ../sm-bm-app/frontend/src/views/OAuthCallbackView.test.ts` prints at least 1 and `grep -c 'oauth-register' ../sm-bm-app/frontend/src/views/OAuthCallbackView.test.ts` prints at least 1.
- `git -C ../sm-bm-app log -1 --format=%H -- frontend/package.json frontend/package-lock.json` prints the same hash before and after this task (record both in the SUMMARY): no commit of this plan touches the dependency files.
- `grep -c 'OAuthCallbackView.test.ts' scripts/check-phase19.5.sh` prints at least 2 and `grep -c 'REQUIRE_FILES' scripts/check-phase19.5.sh` prints at least 2.
</acceptance_criteria>
<done>The bm callback view's ordering, single redeem, error display and pending routing are pinned by a test that runs in the gate, with the frontend's dependencies unchanged.</done>
</task>
<task type="auto">
<name>Task 5: Security review and validation map record the proof-of-address fix, the whole gate is green with Docker required, and the release table is ready for the go-ahead</name>
<files>.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</files>
<read_first>19.5-05-SUMMARY.md draft, .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/phases/19.5-social-login-in-sm-user-plugin/19.5-REVIEW.md (CR-01, CR-02), this plan's threat_model, .planning/G15OFFICE.md</read_first>
<action>19.5-SECURITY-REVIEW.md (D-27: correct the wording):
- Add one sentence to the Result paragraph naming 19.5-05: it closes the password-registration variant through `users.email_verified_at`.
- In the defects table, row d7ee28d: replace the closing claim about the reset with a note that 19.5-05 superseded the self-set-password test (T-19.5-39).
- In the T-19.5-38 "New finding" row:
- remove the parenthetical that says the reset works by setting the self-set-password flag;
- describe the proof as `email_verified_at` (mail activation or a completed reset-code reset);
- status "fixed in d7ee28d; the password-registration variant fixed in 19.5-05 (T-19.5-39)".
- Rows T-19.5-10 and T-19.5-27: point their "creation path" notes at T-19.5-38 and T-19.5-39.
- Row T-19.5-26: closed, with `src/views/OAuthCallbackView.test.ts` (B) as the view half's test.
- Add register rows T-19.5-39 to T-19.5-46 from this plan's threat model, each with the implementation (file and function) and its test (name and repository) or the release control.
- Under a new `## Open findings` heading, record 19.5-REVIEW CR-02 (a pre-planted unverified identity survives the owner's reset): status "open, no user decision; not covered by D-26..D-28". Invent no disposition.
- Extend "Checks the plan names" with "the proof follows the address".
19.5-VALIDATION.md:
- Rows `19.5-05-01` to `19.5-05-07` (task, decision, test names, gate stage, status). Tasks 6 and 7 are remote operations that name Task 7's acceptance criteria.
- Decision-coverage rows D-26, D-27 and D-28.
- The manual-only table keeps the live provider sign-in. The bm callback view is now automated (D-28), so note it under the table.
- Keep `nyquist_compliant: true` and `status: validated`.
Run `PHASE195_REQUIRE_DOCKER=1 bash scripts/check-phase19.5.sh --all` on a Docker host (through `sudo -u golem` if needed) until every stage passes, and record each stage's result in the SUMMARY draft.
Commit the two planning docs together: `docs(19.5): proof of address in the security review and validation map`.
Then add a `## Release table` to the SUMMARY draft:
- per repository (sm-user-plugin, sm-sociallogin-plugin, sm-bm-app, sm-demo-app, fonoteka.go, summercms.go): `git log --oneline origin/master..master` after `git fetch origin`, each commit marked as this plan's or foreign;
- the proposed tags, each confirmed free with `git ls-remote --tags origin <tag>` printing nothing (else the next free patch);
- the pointer-bump commits Task 7 would make.
The Task 6 checkpoint follows.</action>
<verify>
<automated>PHASE195_REQUIRE_DOCKER=1 bash scripts/check-phase19.5.sh --all</automated>
<fails_when>Non-zero exit: any stage (self-test, go, userplugin, sociallogin, demo, bm, fonoteka, docs, docker) reports FAIL, a required test or test file is missing or skipped, or no Docker daemon answered.</fails_when>
</verify>
<acceptance_criteria>
- `grep -cE 'T-19\.5-(39|4[0-6])' .planning/phases/19.5-social-login-in-sm-user-plugin/19.5-SECURITY-REVIEW.md` prints at least 8.
- `grep -c 'email_verified_at' .planning/phases/19.5-social-login-in-sm-user-plugin/19.5-SECURITY-REVIEW.md` prints at least 3 and `grep -c 'which sets .has_self_set_password' .planning/phases/19.5-social-login-in-sm-user-plugin/19.5-SECURITY-REVIEW.md` prints 0.
- `grep -c 'CR-02' .planning/phases/19.5-social-login-in-sm-user-plugin/19.5-SECURITY-REVIEW.md` prints at least 1 and `grep -c 'OAuthCallbackView.test.ts' .planning/phases/19.5-social-login-in-sm-user-plugin/19.5-SECURITY-REVIEW.md` prints at least 1.
- `grep -cE '^\| 19\.5-05-0[1-7] ' .planning/phases/19.5-social-login-in-sm-user-plugin/19.5-VALIDATION.md` prints 7 and `grep -cE '^\| D-2[678] ' .planning/phases/19.5-social-login-in-sm-user-plugin/19.5-VALIDATION.md` prints at least 3.
- `grep -c '^## Release table' .planning/phases/19.5-social-login-in-sm-user-plugin/19.5-05-SUMMARY.md` prints 1.
</acceptance_criteria>
<done>The phase's security record matches the code, the validation map names a test for D-26..D-28, the whole gate is green across six repositories with Docker required, and the release table is ready for the checkpoint.</done>
</task>
<task type="checkpoint:decision" gate="blocking-human">
<name>Task 6: Go-ahead to release sm-user-plugin v0.1.5 and sm-sociallogin-plugin v0.1.2 and to move the application pins (D-21 order)</name>
<read_first>19.5-05-SUMMARY.md draft (Release table, gate results), 19.5-04-SUMMARY.md (Tags and final pins), 19.5-CONTEXT.md (D-21, D-27), CLAUDE.md (Commits and concurrency)</read_first>
<acceptance_criteria>
- The SUMMARY records the reply verbatim under a `## Task 6 reply` heading.
- Before the reply, `T=$(git -C ../fonoteka.go/plugins/golem15/user tag --points-at master) && test -z "$T"` and `T=$(git -C ../sm-sociallogin-plugin tag --points-at master) && test -z "$T"` exit 0, and `git -C ../sm-sociallogin-plugin ls-remote --tags origin v0.1.2` prints nothing (no tag before the go-ahead).
</acceptance_criteria>
<decision>Do the email-proof fix and the bm view test go out now, in D-21 order?</decision>
<context>Tasks 1-5 are committed locally and the gate is green. Nothing is pushed yet.
What applications pin today:
- sm-demo-app and sm-bm-app: sm-user-plugin v0.1.4 (c43ba87) and sm-sociallogin-plugin v0.1.1 (d7ee28d);
- fonoteka.go: v0.1.4.
Until a release, the applications that mount sociallogin keep the password-registration takeover (VERIFICATION item 1). The Release table lists every unpushed commit per repository and flags any that are not this plan's.
Proposed, in order, never force-pushed and never deployed:
1. sm-user-plugin: push master, tag v0.1.5 (the migration adds a nullable column and writes nothing; existing accounts get no proof).
2. sm-sociallogin-plugin: commit `chore: require sm-user-plugin v0.1.5`, push, tag v0.1.2.
3. sm-demo-app and sm-bm-app: commit `chore: bump sm-user-plugin to v0.1.5`, then `chore: bump sm-sociallogin-plugin to v0.1.2`, each with its gate stage green, then push. The sm-bm-app push includes the Task 4 test commit and any foreign commit listed in the table only if you allow it.
4. fonoteka.go: commit `chore: bump sm-user-plugin to v0.1.5` locally (its unpushed range holds other work, so it is not pushed).
There is no framework release, so no changelog post.</context>
<options>
<option id="release">
<name>release</name>
<pros>Both applications that mount sociallogin get the fix as reproducible tags; the plugin's go.mod requires the user plugin that carries the column; D-21 holds.</pros>
<cons>Two tags and pushes of four repositories now; the first boot of each application runs the new migration.</cons>
</option>
<option id="hold">
<name>hold</name>
<pros>Nothing leaves this machine.</pros>
<cons>The applications keep the takeover path; the SUMMARY records the unreleased fix as a deviation and a todo with a g15office task tracks the release.</cons>
</option>
</options>
<resume-signal>Reply "release" or "hold", optionally naming other versions or allowing the foreign commits in the sm-bm-app push. Nothing is ever force-pushed or deployed.</resume-signal>
</task>
<task type="auto">
<name>Task 7: Under the Task 6 reply, release in D-21 order: sm-user-plugin v0.1.5, sm-sociallogin-plugin v0.1.2 requiring it, then separate pointer-bump commits (user first, then sociallogin) in sm-demo-app and sm-bm-app and the user pointer in fonoteka.go</name>
<precondition>19.5-05-SUMMARY.md (draft) has a "Task 6 reply" heading with "release" or "hold".</precondition>
<files>../sm-sociallogin-plugin/go.mod, ../sm-sociallogin-plugin/go.sum, ../sm-demo-app/plugins/golem15/user, ../sm-demo-app/plugins/golem15/sociallogin, ../sm-demo-app/go.mod, ../sm-demo-app/go.sum, ../sm-bm-app/plugins/golem15/user, ../sm-bm-app/plugins/golem15/sociallogin, ../sm-bm-app/go.mod, ../sm-bm-app/go.sum, ../fonoteka.go/plugins/golem15/user, .planning/todos/pending/email-proof-release.md</files>
<read_first>19.5-05-SUMMARY.md draft (Release table, Task 6 reply), 19.5-02-SUMMARY.md ("Remote operations": GOWORK=off go mod tidy, annotated tags), 19.5-03-SUMMARY.md (how the app pointers and go.mod lines were bumped, check-pins.sh, TestGoModRequiresTags), 19.5-04-SUMMARY.md (Tags and final pins), CLAUDE.md (Commits and concurrency), .planning/G15OFFICE.md</read_first>
<action>Act only as the reply allows. Before every push:
- run `git fetch origin`;
- `git status` shows no uncommitted change to this plan's paths;
- `git log --oneline origin/master..master` lists only this plan's commits, plus foreign commits only when the reply allows them. Otherwise stop and report.
Never force-push, never deploy, stage by path.
On "release" (D-21 order):
1. sm-user-plugin (`../fonoteka.go/plugins/golem15/user`):
- vet and the short suite are green through the fonoteka.go workspace;
- push master;
- create an annotated tag v0.1.5 whose user-facing message says: users.email_verified_at records a proved address (mail activation, reset code); an email change clears it; no backfill; the routes, payloads and config are unchanged;
- push the tag.
2. sm-sociallogin-plugin:
- set the go.mod require to `git.golem15.com/golem15/sm-user-plugin v0.1.5`, keeping the summercms replace;
- run `GOWORK=off GOPRIVATE='git.golem15.com/*' go mod tidy`; vet, the short suite and `GOWORK=off go build ./...` are green;
- commit `chore: require sm-user-plugin v0.1.5`;
- push master;
- create an annotated tag v0.1.2 whose user-facing message says: a verified sign-in links only onto an account whose owner proved the address; accounts registered with a password in auto mode, created by an administrator or created before golem15.user v0.1.5 go to the pending screen; requires sm-user-plugin v0.1.5;
- push the tag.
3. sm-demo-app, two commits:
- in `plugins/golem15/user`, fetch tags and check out v0.1.5; set the go.mod require to v0.1.5 and update go.sum as 19.5-03 did; `bash scripts/check-phase19.5.sh --demo` and `scripts/check-pins.sh` are green; commit `chore: bump sm-user-plugin to v0.1.5` (gitlink, go.mod, go.sum);
- then the same for `plugins/golem15/sociallogin` at v0.1.2: commit `chore: bump sm-sociallogin-plugin to v0.1.2`;
- push.
4. sm-bm-app: the same two commits in the same order. Change the go.mod require lines only where they name versions, keep the replace lines, and add the go.sum change Go asks for to the commit it belongs to. `bash scripts/check-phase19.5.sh --bm` is green after each commit. Push, including the Task 4 test commit.
5. fonoteka.go: `plugins/golem15/user` is already the v0.1.5 commit (check with `git -C plugins/golem15/user describe --exact-match`). Run `bash scripts/check-phase19.5.sh --fonoteka`. Stage only `plugins/golem15/user` and commit `chore: bump sm-user-plugin to v0.1.5`. Do not push. Other sessions' changes to feedback and golem stay untouched.
6. Post a short comment on the g15office Phase 19.5 milestone task `onunbz3l`: the gap closure executed and v0.1.5 and v0.1.2 were released, awaiting re-verification. Set its status to Testing (.planning/G15OFFICE.md). If the tool is unavailable, record that in the SUMMARY.
On "hold": push nothing, tag nothing, move no pointer.
- In the SUMMARY, record a deviation that lists the unreleased commits and the pins that lack them.
- Create `.planning/todos/pending/email-proof-release.md` in the existing todo frontmatter style: priority high (security), area sm-user-plugin and sm-sociallogin-plugin. Its body holds the Release table and steps 1-5 above.
- Create its g15office task per .planning/G15OFFICE.md and write the ID into the frontmatter.
- Commit the todo alone: `docs(19.5): capture the email-proof release todo`.
Finalize the SUMMARY: tags, pushed ranges, the pointer-bump commits per application, final pins, and remove the draft status line.</action>
<verify>
<automated>bash scripts/check-phase19.5.sh --sociallogin &amp;&amp; bash scripts/check-phase19.5.sh --demo &amp;&amp; bash scripts/check-phase19.5.sh --bm &amp;&amp; bash scripts/check-phase19.5.sh --fonoteka</automated>
<fails_when>Non-zero exit: the plugin no longer builds at its new require, or an application no longer builds or passes its named tests at the new pins (for example, sociallogin v0.1.2 checked out before the user pin moved). On "hold", this command is not run and the SUMMARY says so.</fails_when>
</verify>
<acceptance_criteria>
- On "release":
- `git -C ../fonoteka.go/plugins/golem15/user ls-remote --exit-code --tags origin v0.1.5` and `git -C ../sm-sociallogin-plugin ls-remote --exit-code --tags origin v0.1.2` exit 0 (or the versions the reply named);
- `grep -c 'git.golem15.com/golem15/sm-user-plugin v0.1.5' ../sm-sociallogin-plugin/go.mod` prints 1.
- On "release", for A in sm-demo-app and sm-bm-app:
- `test "$(git -C ../A rev-parse HEAD:plugins/golem15/user)" = "$(git -C ../fonoteka.go/plugins/golem15/user rev-parse 'v0.1.5^{commit}')"` exits 0;
- `test "$(git -C ../A rev-parse HEAD:plugins/golem15/sociallogin)" = "$(git -C ../sm-sociallogin-plugin rev-parse 'v0.1.2^{commit}')"` exits 0;
- `git -C ../A log --format=%s -2` prints `chore: bump sm-sociallogin-plugin to v0.1.2` above `chore: bump sm-user-plugin to v0.1.5`;
- `L=$(git -C ../A log --oneline origin/master..master) && test -z "$L"` exits 0.
- On "release", for fonoteka.go:
- `test "$(git -C ../fonoteka.go rev-parse HEAD:plugins/golem15/user)" = "$(git -C ../fonoteka.go/plugins/golem15/user rev-parse 'v0.1.5^{commit}')"` exits 0;
- `git -C ../fonoteka.go show --name-only --format= HEAD` prints only `plugins/golem15/user`.
- On "hold", `test -f .planning/todos/pending/email-proof-release.md` exits 0 and the SUMMARY's deviations name every unreleased commit.
</acceptance_criteria>
<done>The applications that mount sociallogin run the email-proof rule at tagged versions, fonoteka.go carries the new user plugin pin locally, or the user's hold is recorded with a tracked todo.</done>
<reversibility rating="costly">Pushed tags and pointer bumps of shared plugins are consumed by other applications. A fix needs a new patch tag, never a moved tag.</reversibility>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| anonymous client to /_user/api/v1/register and /update | anyone can register any address in auto mode and change an account's email; neither proves the mailbox |
| provider callback to account matching | a verified provider email meets an existing account; linking hands that account the provider's sign-in |
| administrator to the Users screen | an administrator creates, activates and edits accounts; none of that proves the user's mailbox |
| migration to production users tables | a shared core plugin's migration runs on every application that bumps it |
| gate script to the phase verdict | a missing or skipped test must fail the gate |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-19.5-39 | Elevation of privilege | sociallogin `ownerVerifiedEmail` (password-registration pre-account takeover, 19.5-REVIEW CR-01) | high | mitigate | Auto-link requires `users.email_verified_at`, which only the mailed activation code and the completed reset code write. Proved by `TestOwnerVerifiedEmail/register_auto_then_google_pending` (callback, register action and completion all refuse) and `legacy_account_pending`. |
| T-19.5-40 | Spoofing | sm-user-plugin `Update` and `FormBeforeUpdate` (proof carried over an email change) | high | mitigate | An email change (not case-only) sets the column to NULL in the same write. Proved by `TestEmailVerifiedAt/update_email_change_clears`, `TestAdminEmailVerifiedAt/admin_email_change_clears` and `TestOwnerVerifiedEmail/email_change_drops_proof`. |
| T-19.5-41 | Tampering | /register and /update request bodies (mass assignment of the proof) | medium | mitigate | `email_verified_at` is not in `Fillable()`, and Update writes an explicit map. Proved by `TestEmailVerifiedAt/register_ignores_submitted_proof` and `update_ignores_submitted_proof`. |
| T-19.5-42 | Elevation of privilege | admin create and Activate action, `/register` auto and admin modes, social sign-in (non-mailbox paths treated as proof) | medium | mitigate | None of these paths writes the column. Proved by `TestEmailVerifiedAt/register_auto_no_proof`, `register_admin_mode_no_proof`, `TestAdminEmailVerifiedAt/admin_create_no_proof`, `admin_activate_action_no_proof` and `TestOwnerVerifiedEmail/admin_activation_not_proof`. |
| T-19.5-43 | Information disclosure | user API payloads and admin JSON (contract drift in a core plugin) | low | mitigate | `json:"-"`; `apiArray` and the admin YAML are unchanged. Proved by the payload-key assertions in `TestEmailVerifiedAt` and by `TestAdminEmailVerifiedAt/admin_json_hides_proof`; the Task 2 acceptance diff shows routes, config, lang and admin YAML unchanged since v0.1.4. |
| T-19.5-44 | Tampering | migration `202610210001_add_users_email_verified_at` (data rewrite or a broken upgrade) | medium | mitigate | `ADD COLUMN IF NOT EXISTS`, no row writes, no-op rollback. The gate's `migration_writes_rows` and `refused_migration_changes` enforce it, and `TestEmailVerifiedAtMigration` (`no_backfill`, `existing_column_kept`, `rollback_keeps_column`) proves it. |
| T-19.5-45 | Repudiation | scripts/check-phase19.5.sh (false green for the new tests) | medium | mitigate | The new names are required in the docker stage. `REQUIRE_FILES` refuses a vitest run without a passing view test file. `--self-test` covers the planted missing-view-file output and every migration-check case. |
| T-19.5-46 | Tampering | release and pointer bumps (Task 7) | medium | mitigate | Runs only after the blocking-human Task 6 reply. The user pointer moves before sociallogin's, so each commit builds. Each app's gate stage runs after every bump. There is a fetch and `origin/master..master` check before every push, with a stop on foreign commits the reply did not allow. Never force-pushed or deployed. fonoteka.go and summercms.go are not pushed. |
| T-19.5-SC | Tampering | module and npm installs | high | mitigate | No Go module and no npm package is added. The bm view test uses `vue`'s own `createRenderer`, and Task 4 refuses a package.json or lockfile change. The gate's `--sociallogin` dependency check still refuses gorilla/sessions and gopkg.in/yaml.v3. |
</threat_model>
<verification>
- Task 1: TestOwnerVerifiedEmail and TestPreAccountTakeover by name; --self-test, --userplugin, --sociallogin, --docker.
- Task 2: TestEmailVerifiedAt, TestAdminEmailVerifiedAt; TestEmailVerifiedAtMigration and TestCreateOrganisationsMigration; --userplugin, --fonoteka, --docker.
- Task 3: TestOwnerVerifiedEmail, TestPreAccountTakeover and TestPHPPendingRegistrationTakeover; --sociallogin (TestPHPMethodMap), --docker.
- Task 4: the view test file and typecheck; no package.json or lockfile change; --self-test, --bm.
- Task 5: `PHASE195_REQUIRE_DOCKER=1 bash scripts/check-phase19.5.sh --all`; the security review and validation map counts.
- Task 6: the reply recorded; no tag before it. Task 7: --sociallogin, --demo, --bm, --fonoteka at the new pins; tags on the remotes; pins equal the tags.
</verification>
<success_criteria>
- D-26: a verified email auto-links only onto an account that proved its address; every no-proof path, including `/register` in auto mode, goes pending, and every mailed proof links.
- D-27: `users.email_verified_at` is additive (no payload, route or config change), has exactly two writers, follows the address and is not backfilled. The READMEs and the security review no longer claim that a self-set password proves the address.
- D-28: the bm callback view's ordering, single redeem, error and pending behaviour are tested without a new npm dependency.
- The gate fails closed on every new test, and `--all` passes with Docker required.
- SC-3 and D-21: under the go-ahead, v0.1.5 and v0.1.2 are tagged and both applications that mount sociallogin, plus fonoteka.go, are green at the new pins; or the hold is recorded and tracked.
</success_criteria>
<output>
Create `.planning/phases/19.5-social-login-in-sm-user-plugin/19.5-05-SUMMARY.md`. It starts as the Task 3 hand-off draft. Include:
- the commits per repository;
- the gate output per stage (PASS, or NOT RUN with the reason);
- the test names added and their subtests;
- any production defect found, with its fix commit;
- the Release table and the Task 6 reply verbatim;
- the tags, the pushed ranges, the pointer bumps and the final pins (or the hold todo and its g15office ID);
- the g15office comment;
- any deviation, including a fallback version number or a Task 4 checkpoint.
</output>

View File

@@ -82,6 +82,12 @@ These answer RESEARCH.md's Open Questions 1, 4 and 6.
- **D-24:** **fonoteka gets the relaxed D-13 unlink rule.** There is no opt-out flag. If a fonoteka parity or test case covers the old 409, it is updated.
- **D-25:** **The phase has 4 plans.** (1) Foundations: `cooler.Pull`, the goth decision note, the user-plugin changes D-13 (unlink with a `SELECT … FOR UPDATE` lock on the user row) and D-15, and the remaining folded-todo fix. (2) sm-sociallogin-plugin. (3) Adoption in sm-demo-app, sm-bm-app and fonoteka. (4) Unit tests and the PHP test map.
### Decisions after verification (2026-10-21, gap closure)
- **D-26:** **Close the password-registration pre-account takeover (VERIFICATION human item 1).** An email match gets a verified provider's identity automatically only when the account has proved its address: activated by mail (`Activate` / `ActivateByCode`) or set its password through the mailed reset code. A self-set password alone (`/register` with `activate_mode=auto`, admin-created) is no longer proof; such a match goes to the pending screen with reason `unverified_match`, and the owner links from the settings. Diverges from PHP D-10 on purpose. Existing accounts with no recorded proof take the pending path.
- **D-27:** **Proof is an additive column in sm-user-plugin: `users.email_verified_at` (nullable timestamp), set by mail activation and by a completed reset-code password reset.** This relaxes D-22's "no sm-user-plugin migration" for this follow-up only; the change is additive (no API payload, route or config key changes) and ships as sm-user-plugin v0.1.5, then sm-sociallogin-plugin v0.1.2, then pointer bumps in sm-demo-app, sm-bm-app and fonoteka.go (D-21 order, push and tags on the user's go-ahead). README and 19.5-SECURITY-REVIEW.md wording that a self-set password "proved the address" is corrected.
- **D-28:** **bm's `OAuthCallbackView` gets a vitest view test** (VERIFICATION truth 16): `router.replace` drops `?code=` before the single `oauth-complete` call, a second mount does not redeem again, `?error=` shows the message and `?pending_registration=` routes to `oauth-register`. No new npm dependency.
### Claude's Discretion
- RESEARCH.md's recommendations settle the other open questions:
- Login touches last-seen and the IP address, with no new event.