diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md
index 11a24599..a64fc7b1 100644
--- a/.planning/ROADMAP.md
+++ b/.planning/ROADMAP.md
@@ -1395,10 +1395,20 @@ Plans:
3. Device authorization (`devices/initiate`, `devices/status/{token}`, `devices/authorize`, and the device list and revoke) works on the `DeviceAuthSession` model.
4. The models TwoFactorMethod, TwoFactorChallenge, TwoFactorRecoveryCode, TrustedDevice, WebAuthnCredential and DeviceAuthSession and their migrations are ported. The phase gets a security review. 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.6 to break down)
+**Wave 1**
+- [ ] 19.6-01-PLAN.md — Foundations: decision note for pquerna/otp, go-webauthn and boombuler/barcode, sm-user-plugin seams (login gates in Login with the restore after the gate, login and password-changed events, reset-2FA slot and permission), device authorization flow with the JTI followed through refresh, human package check (wave 1)
+
+**Wave 2** *(blocked on Wave 1 completion)*
+- [ ] 19.6-02-PLAN.md — New repo sm-mfa-plugin: TOTP tracer through the challenge, recovery and email codes with the D-18 hardening, trusted devices, settings screen, revocation on password change, admin reset service (wave 2)
+
+**Wave 3** *(blocked on Wave 2 completion)*
+- [ ] 19.6-03-PLAN.md — WebAuthn second factor and passwordless passkeys, social sign-in through the gate, demo 2FA screens in vue-demo-app, release go-ahead and publishing in D-17 order, adoption in sm-demo-app and sm-bm-app, fonoteka.go pointer and guard tests (wave 3)
+
+**Wave 4** *(blocked on Wave 3 completion)*
+- [ ] 19.6-04-PLAN.md — Unit tests for every decision, login-path invariant, PHP security test map, security review with the explicit D-14 section, scripts/check-phase19.6.sh, gated release of post-release fixes (wave 4)
### Phase 19.7: Generic MCP module (INSERTED)
diff --git a/.planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-01-PLAN.md b/.planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-01-PLAN.md
new file mode 100644
index 00000000..8454cb0e
--- /dev/null
+++ b/.planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-01-PLAN.md
@@ -0,0 +1,420 @@
+---
+phase: 19.6-two-factor-auth-and-device-flow-in-sm-user-plugin
+plan: 01
+type: execute
+wave: 1
+depends_on: []
+files_modified:
+ - .planning/notes/2026-10-21-mfa-otp-webauthn.md
+ - .planning/research/STACK.md
+ - ../fonoteka.go/plugins/golem15/user/classes/login_gate.go
+ - ../fonoteka.go/plugins/golem15/user/classes/events.go
+ - ../fonoteka.go/plugins/golem15/user/classes/two_factor_admin.go
+ - ../fonoteka.go/plugins/golem15/user/classes/device_auth.go
+ - ../fonoteka.go/plugins/golem15/user/controllers/login_gates.go
+ - ../fonoteka.go/plugins/golem15/user/controllers/api_controller.go
+ - ../fonoteka.go/plugins/golem15/user/controllers/registration.go
+ - ../fonoteka.go/plugins/golem15/user/controllers/users_admin_controller.go
+ - ../fonoteka.go/plugins/golem15/user/controllers/users/config_form.yaml
+ - ../fonoteka.go/plugins/golem15/user/controllers/devices.go
+ - ../fonoteka.go/plugins/golem15/user/models/device_auth_session.go
+ - ../fonoteka.go/plugins/golem15/user/updates/202610210002_create_device_auth_sessions.go
+ - ../fonoteka.go/plugins/golem15/user/admin_permissions.go
+ - ../fonoteka.go/plugins/golem15/user/plugin.go
+ - ../fonoteka.go/plugins/golem15/user/routes.go
+ - ../fonoteka.go/plugins/golem15/user/config/config.yaml
+ - ../fonoteka.go/plugins/golem15/user/lang/en/lang.yaml
+ - ../fonoteka.go/plugins/golem15/user/lang/pl/lang.yaml
+ - ../fonoteka.go/plugins/golem15/user/login_gate_smoke_test.go
+ - ../fonoteka.go/plugins/golem15/user/password_changed_smoke_test.go
+ - ../fonoteka.go/plugins/golem15/user/admin_registration_test.go
+ - ../fonoteka.go/plugins/golem15/user/reset_two_factor_smoke_test.go
+ - ../fonoteka.go/plugins/golem15/user/device_flow_smoke_test.go
+ - ../fonoteka.go/plugins/golem15/user/device_revoke_smoke_test.go
+ - ../fonoteka.go/plugins/golem15/user/README.md
+autonomous: false
+requirements: [SC-2, SC-3, SC-4, D-02, D-03, D-04, D-06, D-07, D-11, D-12a, D-12b, D-12c, D-15, D-16, D-17, D-18]
+estimate:
+ tokens: 290000
+ raw_tokens: 290000
+ tasks: 5
+ confidence: low
+must_haves:
+ truths:
+ - "D-04: a decision note .planning/notes/2026-10-21-mfa-otp-webauthn.md names github.com/pquerna/otp v1.5.0, github.com/go-webauthn/webauthn v0.18.2 and github.com/boombuler/barcode v1.1.0 (QR, with the plugin's own SVG writer), the measured +2,269,184-byte binary delta, the nine newly linked modules, the go-viper/mapstructure v2.5.0 bump, the clean govulncheck run, the D-08 JSON-shape deviation and the blocking human package check; .planning/research/STACK.md lists the three pins; the note is committed before any go.mod requires one of them, and no go.mod in sm-user-plugin, the framework or fonoteka.go ever requires them."
+ - "D-02: sm-user-plugin publishes a *classes.LoginGates registry in Register; Login consults it after CheckAndRecordLogin(..., true) and the bcrypt rehash and before the soft-delete restore and the mint; a gate result answers its own status and JSON body (HTTP 200 two_factor_required for a challenge) and no token is minted; a gate error answers the opaque 500; an empty registry leaves Login's responses byte-identical to v0.1.5; JWT refresh never consults the gate."
+ - "D-18 (A10): with a gate that challenges, a soft-deleted account stays soft-deleted and no reactivate mail is sent; the restore and the reactivate mail run only after the gate passed."
+ - "D-02, D-05 support: every successful password login fires classes.LoginEvent (Source password) exactly once, after the token is minted; a challenged login fires none; controllers.FireLoginEvent and controllers.CheckLoginGates are exported for sm-mfa-plugin and sm-sociallogin-plugin."
+ - "D-07: classes.PasswordChangedEvent fires inside the password-write transaction from ResetPassword (Source reset), ChangePassword (Source change) and the admin password set (Source admin), and a listener error rolls the write back; the bcrypt rehash on login and RegisterUser fire none."
+ - "D-15: the user plugin registers the backend permission golem15.users.reset_two_factor (no default role) and a reset_two_factor record action on the Users screen with Danger true; Applies is false when no classes.TwoFactorAdmin service is published, so applications without sm-mfa-plugin never show it; Run calls the published service's Reset inside cabana's transaction."
+ - "D-03, D-11, D-16: POST /_user/api/v1/devices/initiate, GET devices/status/{token}, POST devices/authorize, GET devices and DELETE devices/{id} (id [0-9]+) live in the user-api group; the session row is golem15_user_device_auth_sessions with PHP's column names, a 64-character token stored as its SHA-256 hex (D-06), an XXXX-XXXX short code over 23456789ABCDEFGHJKMNPQRSTUVWXYZ, a 5-minute TTL, PHP's status set pending|confirmed|used|expired|revoked and an added jwt_jti column; auth_url comes from golem15.user.device_auth.auth_url (default {frontend_url}/device/scan/{token})."
+ - "D-12a: the first poll after confirmation answers {success:true, data:{status:'confirmed', user_id, message:'Device authorized - complete login', token, user}} through one guarded confirmed-to-used UPDATE; of concurrent polls exactly one receives a token and the others answer status used; used and revoked sessions never turn into expired."
+ - "D-12b: DELETE devices/{id} marks the row revoked and adds its jwt_jti to the JWT blacklist; Refresh moves the row's jwt_jti from the old token's jti to the new one, so a device that refreshed before the revoke is signed out too."
+ - "D-12c: POST devices/authorize needs only a valid, non-blacklisted bearer JWT (PHP 401 body {\"error\":\"Unauthorized\"} otherwise) and binds a pending session with no user to the caller through one guarded pending-to-confirmed UPDATE; there is no second-factor re-check."
+ - "D-17 support: every change to sm-user-plugin is additive (no route, payload, table, column, migration ID or config key of v0.1.5 changes); fonoteka.go, sm-sociallogin-plugin and sm-oauthserver-plugin compile against the checkout; nothing is pushed or tagged in this plan."
+ artifacts:
+ - path: ".planning/notes/2026-10-21-mfa-otp-webauthn.md"
+ provides: "D-04 decision note naming the three pinned libraries and the human package check"
+ contains: "go-webauthn/webauthn v0.18.2"
+ - path: "../fonoteka.go/plugins/golem15/user/classes/login_gate.go"
+ provides: "LoginAttempt, LoginGateResult, LoginGate, LoginGates"
+ contains: "type LoginGate interface"
+ - path: "../fonoteka.go/plugins/golem15/user/controllers/devices.go"
+ provides: "device authorization handlers"
+ contains: "func DeviceStatus"
+ - path: "../fonoteka.go/plugins/golem15/user/updates/202610210002_create_device_auth_sessions.go"
+ provides: "golem15_user_device_auth_sessions table"
+ contains: "golem15_user_device_auth_sessions"
+ - path: "../fonoteka.go/plugins/golem15/user/login_gate_smoke_test.go"
+ provides: "TestLoginGatesSmoke and TestLoginGateSmoke"
+ contains: "TestLoginGateSmoke"
+ key_links:
+ - from: "../fonoteka.go/plugins/golem15/user/controllers/api_controller.go"
+ to: "../fonoteka.go/plugins/golem15/user/classes/login_gate.go"
+ via: "Login calls CheckLoginGates between CheckAndRecordLogin and the restore and mint"
+ pattern: "CheckLoginGates\\("
+ - from: "../fonoteka.go/plugins/golem15/user/controllers/api_controller.go"
+ to: "golem15_user_device_auth_sessions.jwt_jti"
+ via: "Refresh hands the device row's jti over to the refreshed token"
+ pattern: "HandOverDeviceJTI"
+ - from: "../fonoteka.go/plugins/golem15/user/controllers/devices.go"
+ to: "bouncer.BlacklistStore"
+ via: "DeviceDestroy blacklists the row's jwt_jti"
+ pattern: "Lookup\\[bouncer.BlacklistStore\\]"
+ - from: "../fonoteka.go/plugins/golem15/user/controllers/users_admin_controller.go"
+ to: "../fonoteka.go/plugins/golem15/user/classes/two_factor_admin.go"
+ via: "reset_two_factor looks up the published TwoFactorAdmin per request"
+ pattern: "Lookup\\[classes.TwoFactorAdmin\\]"
+ prohibitions:
+ - statement: "No JWT, device token, short code, challenge token or password 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 changes are additive only: no existing route, response body, table, column, migration ID or config key changes; the only new migration is the device-session table (core plugin contract, 19.4 D-05)."
+ status: unresolved
+ verification: judgment
+ - statement: "No go.mod in sm-user-plugin, summercms.go or fonoteka.go requires pquerna/otp, go-webauthn or boombuler/barcode, and none is installed anywhere before the Task 5 reply."
+ status: unresolved
+ verification: judgment
+ - statement: "Nothing is pushed or tagged and no remote repository is created in this plan (plan 03 asks for the go-ahead)."
+ status: unresolved
+ verification: judgment
+ - statement: "Framework code and plugin READMEs name no consuming application (neutral wording: the application)."
+ status: unresolved
+ verification: judgment
+---
+
+
+
+
+
+## Phase Goal
+
+ROADMAP Goal (not in user-story format; MVP mode would take a story from `/msd mvp-phase 19.6`, which was not run, so no story is invented here, as in 19.5): `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. CONTEXT D-01 moves the second factors into a new shared plugin, `sm-mfa-plugin`; D-03 keeps the device flow in `sm-user-plugin`.
+
+This plan's slice (plan 1 of 4, wave 1, "Foundations"): the decision note for the three libraries lands first. Then sm-user-plugin gets its additive seams: the login-gate registry wired into Login (the tracer), the login and password-changed events, the admin reset slot, and the whole device authorization flow with the JTI hand-over on refresh. The plan ends with the blocking human package check, so plan 02 can install the libraries.
+
+## 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 CONTEXT decisions D-01..D-20 (with D-12a, D-12b, D-12c); `requirements` lists those IDs for traceability.
+
+
+- Primary noun for authentication: before this phase a sign-in was "password (or social identity) → token". After it, it is "first factor (password, social identity) → optional second factors (TOTP, email code, recovery code, WebAuthn) → token", plus a passkey as an alternative first factor that satisfies the second factor by itself (D-14).
+- Decision: **add-alongside**. Password login stays the primary path and its contract is unchanged when no gate is registered (D-02). Second factors are layered on through a registry of gates that sm-mfa-plugin fills; applications without sm-mfa-plugin behave exactly as before (D-01).
+- Accepted debt: token mints that do not consult the gate, each by decision: JWT refresh (D-02, PHP), POST /register and activate-by-code (a new or not-yet-activated account cannot have 2FA yet), sociallogin's action=link (the caller is already signed in), the device poll (D-12c: the authorizing session already passed 2FA), the challenge completion and the passwordless passkey login (they are the second factor), and fonoteka's onboarding IssueToken (fonoteka does not mount mfa, D-17). Social sign-in consults the gate only when the mfa setting turns it on (D-09, default skip).
+- Invariant test (plan 04): `TestEveryLoginPathIsGated` in sm-mfa-plugin drives every first-factor path (password; social with the toggle on) for a user with TOTP enabled and asserts a challenge and no token, and asserts that passwordless issues a token only for a user-verified discoverable passkey and the device poll only after an authorize by a bearer JWT.
+
+
+## Phase source audit (all four plans)
+
+| SOURCE | ID | Item | Plan | Status |
+|---|---|---|---|---|
+| GOAL | — | Second factors, challenge step and device login of the PHP user plugin | 01-03 | COVERED |
+| ROADMAP | SC-1 | TOTP, WebAuthn, email codes, recovery codes, trusted devices; WebAuthn decision note first | 01 (note), 02, 03 | COVERED |
+| ROADMAP | SC-2 | Challenge step behind the 2fa-verify bucket | 01 (gate), 02 | COVERED |
+| ROADMAP | SC-3 | Device authorization on DeviceAuthSession | 01 | COVERED |
+| ROADMAP | SC-4 | Six models and migrations, security review, unit tests last | 01, 02, 03, 04 | COVERED |
+| CONTEXT | D-01 | Separate sm-mfa-plugin requiring the user plugin | 02, 03 (repo creation) | COVERED |
+| CONTEXT | D-02 | Login-gate registry | 01 | COVERED |
+| CONTEXT | D-03 | Device flow in sm-user-plugin | 01 | COVERED |
+| CONTEXT | D-04 | Libraries, decision note, QR pick | 01, 02, 03 | COVERED |
+| CONTEXT | D-05 | PHP paths, fields, shapes, bucket, TTLs, events | 02, 03 | COVERED |
+| CONTEXT | D-06 | SHA-256 tokens at rest, encrypted TOTP secret, slow hash | 01, 02 | COVERED |
+| CONTEXT | D-07 | Revocation on password change and last-method disable | 01, 02 | COVERED |
+| CONTEXT | D-08 | Standard WebAuthn JSON shapes | 03 | COVERED |
+| CONTEXT | D-09 | Social login through the challenge behind a setting | 02 (setting), 03 | COVERED |
+| CONTEXT | D-10 | Trusted devices | 02 | COVERED |
+| CONTEXT | D-11, D-12a, D-12b, D-12c | Device flow contract, one-time JWT, revoke, authorize | 01 | COVERED |
+| CONTEXT | D-12 | Settings screen | 02 | COVERED |
+| CONTEXT | D-13 | No child exemption | 02 | COVERED |
+| CONTEXT | D-14 | Passwordless passkey routes and security review | 03, 04 | COVERED |
+| CONTEXT | D-15 | Admin Reset 2FA | 01 (slot), 02 (service) | COVERED |
+| CONTEXT | D-16 | Six tables with PHP names | 01, 02, 03 | COVERED |
+| CONTEXT | D-17 | Adoption in demo and bm, fonoteka guard test | 03 | COVERED |
+| CONTEXT | D-18 | A6, A8, A9, A10 hardening | 01 (A10), 02 | COVERED |
+| CONTEXT | D-19 | First recovery set returned | 02 | COVERED |
+| CONTEXT | D-20 | Demo 2FA UI in vue-demo-app | 03 | COVERED |
+| RESEARCH | Q7 | Refresh lineage gap (JTI hand-over) | 01 | COVERED |
+| RESEARCH | Pitfalls 1-21 | Throttle reset, replay, races, RP ID, used-not-expired, name bounds, table growth, Where binding, postcard ownership, settings types, boot without DB | 01-03 | COVERED |
+| RESEARCH | Validation Architecture | check-phase19.6.sh, PHP map, decision-to-test map | 04 | COVERED |
+
+Deferred and not planned (CONTEXT Deferred Ideas): per-group exemptions, notification mails on 2FA changes, the Winter import of the 2FA and device tables.
+
+## Decisions taken in this plan (recorded assumptions and Claude's discretion)
+
+- Gate identifiers (CONTEXT discretion): `classes.LoginAttempt{User *models.User; Source string; Fields map[string]any; Request *http.Request}`; source constants `classes.LoginSourcePassword` ("password"), `LoginSourceSocial` ("social"), `LoginSourcePasskey` ("passkey"), `LoginSourceTwoFactor` ("two_factor"), `LoginSourceDevice` ("device"); `classes.LoginGateResult{Status int; Body map[string]any}`; `classes.LoginGate` with one method `CheckLogin(ctx context.Context, a LoginAttempt) (*LoginGateResult, error)`; `classes.LoginGates` with `NewLoginGates()`, `Add(LoginGate)` and `Check(ctx, LoginAttempt) (*LoginGateResult, error)`; `controllers.CheckLoginGates(ctx, app, a)`. A result carries a status so sm-mfa-plugin can refuse a new challenge with a 401 when the D-18 A6 failure cap is reached; a challenge is Status 200.
+- Login order after this plan (D-02 refined by D-18 A10): lookup, throttle check, password check, `CheckAndRecordLogin(..., true)`, bcrypt rehash (before the gate, so users who are always challenged still get rehashed; a rehash is not a password change, Pitfall 3), gate, soft-delete restore with the reactivate mail, `TouchLastSeen`, mint, `LoginEvent`, response.
+- Event names: `classes.LoginEventName = "golem15.user.login"` (PHP's name, D-05 "then the user login event"); `classes.PasswordChangedEventName = "user.PasswordChanged"` (Go-originated, the style of `user.Created`); sources `classes.PasswordChangeSourceReset`, `PasswordChangeSourceChange`, `PasswordChangeSourceAdmin`.
+- Device routes authenticate inside the handlers, as PHP's controller does (its routes carry no jwt.auth), with PHP's 401 body `{"error":"Unauthorized"}`; the bearer token is parsed in the handler anyway because `is_current` compares its jti with the row's `jwt_jti` (PHP compared a CMS session id).
+- The device table keeps PHP's column name `token` for the SHA-256 hex (64 characters fit PHP's VARCHAR(64), RESEARCH Q4), with Go field `TokenHash`; `session_id` is kept (always NULL) so a Winter import stays a near-straight copy (D-16).
+- `device_name` in `GET devices` uses PHP's model accessor (stored name, else the user-agent parse: iPhone, iPad, Android Device, Windows PC, Mac, Linux PC, Unknown Device). PHP's list read a nonexistent `device_name_attribute` and always answered null; the key and type stay, the value is filled (recorded deviation).
+- The device hand-over fires `LoginEvent` with Source device (PHP's CMS component logged the device in through Auth).
+- Re-authorizing a session that the same caller already confirmed answers PHP's success body (PHP's `confirm()` returned false silently); any other non-pending session answers 400 `Invalid or expired code`.
+- New config keys (additive): `golem15.user.frontend_url` (default empty, meaning app.url), `golem15.user.device_auth.auth_url` (default `{frontend_url}/device/scan/{token}`), `golem15.user.device_auth.ttl_minutes` (default 5).
+- Migration ID `202610210002_create_device_auth_sessions` (after the existing `202610210001_add_users_email_verified_at`).
+- Reversibility: D-02 and D-15 add exported seams other plugins implement (costly, flagged); the device table becomes one-way only when released, which plan 03's go-ahead gates. D-01, D-08 and D-14 were chosen by the user in CONTEXT, so no checkpoint re-asks them.
+- Concurrency with Phase 19.8: its plan 03 Task 8 may sweep this same sm-user-plugin checkout and only runs when the checkout is clean and in sync with origin. This plan therefore starts only when the checkout is clean and `origin/master..master` is empty (Task 1 precondition), and it never pushes.
+- Context budget (estimate 290k, 5 tasks; the project's lean-planning rule fixes four plans): the first agent runs Tasks 1-3; if its context runs low after Task 3, it writes `19.6-01-SUMMARY.md` as a draft (`Status: draft (hand-off after Task 3)`, commits with hashes, test names, NOT RUN notes) and a fresh agent runs Task 4 and the Task 5 checkpoint from this plan, the draft and the repositories on disk.
+
+## Artifacts this phase produces (this plan)
+
+- Decision note `.planning/notes/2026-10-21-mfa-otp-webauthn.md`; three rows in `.planning/research/STACK.md` Supporting Libraries.
+- sm-user-plugin (`git.golem15.com/golem15/sm-user-plugin`):
+ - classes: `LoginAttempt`, `LoginGateResult`, `LoginGate`, `LoginGates`, `NewLoginGates`, `(*LoginGates).Add`, `(*LoginGates).Check`; constants `LoginSourcePassword`, `LoginSourceSocial`, `LoginSourcePasskey`, `LoginSourceTwoFactor`, `LoginSourceDevice`; `LoginEvent{User, Source}`, `LoginEventName`; `PasswordChangedEvent{User, Tx, Source}`, `PasswordChangedEventName`, `PasswordChangeSourceReset`, `PasswordChangeSourceChange`, `PasswordChangeSourceAdmin`; `TwoFactorAdmin` interface (`HasTwoFactor(ctx context.Context, tx *gorm.DB, userID uint) (bool, error)`, `Reset(ctx context.Context, tx *gorm.DB, user *models.User) error`); `PermissionResetTwoFactor` (`golem15.users.reset_two_factor`); device helpers `DeviceShortCodeAlphabet`, `NewDeviceToken() (string, error)`, `NewDeviceShortCode() (string, error)`, `NormalizeDeviceShortCode(s string) (string, bool)`, `DeviceAuthURL(app *backpack.App, token string) string`, `DeviceAuthTTL(app *backpack.App) time.Duration`, `DeviceDisplayName(stored *string, userAgent string) string`, `HandOverDeviceJTI(ctx context.Context, db *gorm.DB, oldJTI, newJTI string) error`.
+ - controllers: `CheckLoginGates(ctx context.Context, app *backpack.App, a classes.LoginAttempt) (*classes.LoginGateResult, error)`, `FireLoginEvent(ctx context.Context, app *backpack.App, user *models.User, source string) error`, `FirePasswordChangedEvent(ctx context.Context, app *backpack.App, user *models.User, tx *gorm.DB, source string) error`, handler factories `DeviceInitiate`, `DeviceStatus`, `DeviceAuthorize`, `DeviceIndex`, `DeviceDestroy`; record action `reset_two_factor` on the Users screen.
+ - models: `DeviceAuthSession` (table `golem15_user_device_auth_sessions`), constants `DeviceStatusPending`, `DeviceStatusConfirmed`, `DeviceStatusUsed`, `DeviceStatusExpired`, `DeviceStatusRevoked`.
+ - migration `202610210002_create_device_auth_sessions`.
+ - routes (user-api group, throttle:user-api): `POST /_user/api/v1/devices/initiate`, `GET /_user/api/v1/devices/status/{token}`, `POST /_user/api/v1/devices/authorize`, `GET /_user/api/v1/devices`, `DELETE /_user/api/v1/devices/{id}`.
+ - config keys `golem15.user.frontend_url`, `golem15.user.device_auth.auth_url`, `golem15.user.device_auth.ttl_minutes`; permission `golem15.users.reset_two_factor`; lang keys `golem15.user::lang.plugin.reset_two_factor`, `golem15.user::lang.users.reset_two_factor`, `.reset_two_factor_confirm`, `.reset_two_factor_success`.
+ - smoke tests `TestLoginGatesSmoke`, `TestLoginGateSmoke`, `TestPasswordChangedEventSmoke`, `TestResetTwoFactorSlotSmoke`, `TestDeviceCodesSmoke`, `TestDeviceFlowSmoke`, `TestDeviceRevokeSmoke`.
+
+
+Record the library decision, then give sm-user-plugin every additive seam the phase builds on: the login gate (tracer), the login and password-changed events, the admin reset slot and the complete device authorization flow with JTI tracking through refresh; stop for the human package check.
+
+Purpose: D-02, D-03, D-04, D-06 (device tokens), D-07 (hook), D-11, D-12a, D-12b, D-12c, D-15 (slot), D-16 (device table), D-17 (additive, fonoteka compiles), D-18 A10; SC-2 (the gate), SC-3 (device authorization), SC-4 (DeviceAuthSession and its migration).
+Output: one planning-doc commit (note and STACK.md), four sm-user-plugin commits each with its README change, the recorded human reply.
+
+
+
+@~/.claude/msd-core/workflows/execute-plan.md
+@~/.claude/msd-core/templates/summary.md
+
+
+
+@.planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-CONTEXT.md
+@.planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-RESEARCH.md
+@.planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-PATTERNS.md
+
+Repos and concurrency: this plan writes in summercms.go (planning docs only) and in the sm-user-plugin repository checked out at ../fonoteka.go/plugins/golem15/user (a submodule of fonoteka.go with its own git; use `git -C ../fonoteka.go/plugins/golem15/user`). Never stage anything in the fonoteka.go superproject (its feedback and golem pointer moves belong to other sessions; plan 03 commits the user pointer). Phase 19.8 plan 03 may sweep the same checkout: before Task 1 run `git -C ../fonoteka.go/plugins/golem15/user fetch origin`, then `status --porcelain` and `log --oneline origin/master..master`; both must be empty, else stop and report. fonoteka.go's own fonoteka-plugin tests `TestNoDeferredRoutesResolve` (bans /devices) and the user-model count in `plugins/golem15/fonoteka/classes/hidden_marshal_test.go` turn red once the device routes and model exist in this checkout; that is expected until plan 03's fonoteka pointer-bump commit updates them (D-17), so this plan's verify commands run the user plugin's suite and fonoteka's build, not the fonoteka plugin's suite. No co-author tags; stage by explicit path; planning docs and code in separate commits; README in the same commit as the API it describes. Keep `go vet` and `go test -short` of the user plugin green at every commit. Docker-backed tests skip under -short; run them on a Docker host (through `sudo -u golem` if the socket is denied); Phase 19.8 found this host's container networking unreachable, so a Docker test that cannot run is recorded as NOT RUN in the SUMMARY, never as passed.
+
+PHP source of truth (read-only): /media/nvme/dev/jz/wavepath.org/plugins/golem15/user/controllers/ApiController.php (login challenge :55-76, refresh :149-174, device list :1175-1202, revoke :1208-1227, authorize :1233-1270, initiate :1277-1308, status :1315-1347), models/DeviceAuthSession.php (status constants :86-90, short code :142-160, findValidShortCode :168, findValidToken :204, confirm :262, device name accessor :337-361, getAuthUrl :388), updates/v2.1.0-v2.7.1 device migrations, routes.php :191-225. RESEARCH Q4 quotes them; the PHP files break ties. The only device-flow client is /media/nvme/dev/golem15/queststream.online/vue-queststream-app/composables/useDeviceAuth.ts (reads `response.data || response`, polls every 2 s, stops on expired or confirmed).
+
+
+sm-user-plugin v0.1.5 (paths relative to ../fonoteka.go/plugins/golem15/user):
+- controllers/api_controller.go: `Login` :47-127 (readFields :53, restore block :87-93, `CheckAndRecordLogin(ctx, gdb, user, ip, true)` :94-101, rehash :102-110, `TouchLastSeen` :112, `mintFor` :115, final `writeJSON(w, http.StatusOK, map[string]any{"token": token, "user": payload})` :125); `Refresh` :179-213 (`bouncer.Refresh(secret, raw, refreshTTL(app), bl, blacklistGrace(app), requestURL(r))` :194, best-effort TouchLastSeen block :204-210); `ResetPassword` password write :327-343; `ChangePassword` :580-597; `authenticate` :795-825 (VerifyClaims, NewJWTGuard(...).Authenticate, user load); `sessionDeps` :827; `mintFor` :877-884 (discards the jti); `RemoteIP` :1077; `writeUnauthorized` :1104 (`{"error": true, "message": "Unauthorized"}`, not PHP's device body); `writeValidation` :946; `Logout` blacklist call (`bl.Add(r.Context(), jti, exp, time.Now())`).
+- controllers/registration.go: `FinishLogin` :99, `FireDeactivatingEvent` :166-171 (pattern for the new Fire helpers), `IssueToken` :176, `APIArray` :189.
+- controllers/users_admin_controller.go: `usersAdminController{app func() *backpack.App}` :37; admin password set in FormBeforeUpdate :238-247 (`changed, err := c.storePassword(ctx, user)`); `AdminRecordActions` :665-730 (the `action(...)` closure, `cabana.TxFromContext`); `requestDB(ctx, c.db)` in controllers/request_db.go.
+- controllers/users/config_form.yaml :17 `recordActions: [activate, unban, unsuspend]`; controllers/api_tokens.go (List :66-85 with `wire.Slice`, Destroy :88-111 with scoped `Where("user_id = ?", ...)`).
+- classes/events.go (event types and name constants, DeactivatingEvent :59-67); classes/oauth_providers.go (seam doc with the explicit-type Publish warning); classes/api_tokens.go `HashAPIToken` :96 (SHA-256 hex, the D-06 scheme); classes/privileged.go :16 `PermissionManagePrivilegedGroups`.
+- plugin.go: `Register` :62-65 (publish the registry here), `Boot` (typed `app.Publish[bouncer.BlacklistStore](bl)`), `Buckets` user-api :150-171; routes.go :17-37 (`g.Delete("/tokens/{id}", ...)` then `g.Where("id", "[0-9]+")`); admin_permissions.go (manage_privileged_groups without Roles); config/config.yaml; updates/202610040001_create_api_tokens.go (raw-SQL migration with `Register(...)` in init); models/api_token.go (model style).
+- tests: session_test.go `sessionApp` :446, `insertUser` :382, `loginToken` :364, `bearer` :405, `TestLogin` :54, `TestRefresh` :225; admin_harness_test.go `newAdminEnv` :131, `(*adminEnv).admin` :197, `call` :242, `expect` :371, `mails` :325.
+- Framework: `bouncer.Mint(secret, sub, issuerURL string, ttl time.Duration) (token, jti string, err error)` (mint.go:31), `bouncer.VerifyClaims(token, secret) (sub string, iat, exp time.Time, jti string, err error)` (jwt.go:194), `bouncer.BlacklistStore.Add(ctx, jti string, expiresAt, validUntil time.Time) error` (blacklist.go:16), `app.Events.Fire(ctx, &T{})`, `app.Events.Listen[*T](owner, fn)` (festival), `wire.Time`, `wire.Slice`, `cabana.TxFromContext`, `pact.AdminRecordAction{Name, Label, Confirm, Permissions, Danger, Applies, Run}`.
+- RESEARCH Code Examples 1 (registry), 7 (device hand-over), 8 (refresh hand-over); Patterns 1, 2, 4, 5; Pitfalls 1, 2, 3, 13, 14, 15, 16, 17, 21.
+- Decision-note structure: .planning/notes/2026-10-20-sociallogin-goth.md (Decision, Date, Status, Context, Decision bullets, measurements, rejected alternatives, Related dependencies, Package legitimacy, Consequences).
+
+
+
+
+
+
+ Task 1: Tracer: the library note lands first, then a password login passes through a registered gate end to end: no gate keeps the old answer, a challenging gate replaces the token with its body and leaves a deactivated account untouched, an erroring gate fails closed, and a successful login fires the login event
+ `git -C ../fonoteka.go/plugins/golem15/user status --porcelain` prints nothing and, after `git -C ../fonoteka.go/plugins/golem15/user fetch origin`, `git -C ../fonoteka.go/plugins/golem15/user log --oneline origin/master..master` prints nothing.
+ D-02: LoginGate, LoginGates, LoginAttempt and CheckLoginGates become exported API of the shared core plugin that sm-mfa-plugin and sm-sociallogin-plugin implement and call.
+ .planning/notes/2026-10-21-mfa-otp-webauthn.md, .planning/research/STACK.md, ../fonoteka.go/plugins/golem15/user/classes/login_gate.go, ../fonoteka.go/plugins/golem15/user/classes/events.go, ../fonoteka.go/plugins/golem15/user/controllers/login_gates.go, ../fonoteka.go/plugins/golem15/user/controllers/api_controller.go, ../fonoteka.go/plugins/golem15/user/controllers/registration.go, ../fonoteka.go/plugins/golem15/user/plugin.go, ../fonoteka.go/plugins/golem15/user/login_gate_smoke_test.go, ../fonoteka.go/plugins/golem15/user/README.md
+ .planning/notes/2026-10-20-sociallogin-goth.md (structure to copy), .planning/research/STACK.md (Supporting Libraries table), 19.6-RESEARCH.md (Summary, Q1, Q2, Q3, Package Legitimacy Audit, Pattern 1, Code Example 1, Pitfalls 1-3 and 21, Assumptions A1 and A13), 19.6-CONTEXT.md (D-02, D-04, D-08, D-18), ../fonoteka.go/plugins/golem15/user/controllers/api_controller.go (Login :47-127), ../fonoteka.go/plugins/golem15/user/controllers/registration.go, ../fonoteka.go/plugins/golem15/user/classes/events.go, ../fonoteka.go/plugins/golem15/user/classes/oauth_providers.go, ../fonoteka.go/plugins/golem15/user/plugin.go (:56-110), ../fonoteka.go/plugins/golem15/user/session_test.go (TestLogin, sessionApp, insertUser), ../fonoteka.go/plugins/golem15/user/README.md (Features, API reference)
+ Planning doc first (D-04). Write `.planning/notes/2026-10-21-mfa-otp-webauthn.md` in the structure of the 2026-10-20 note: title "Decision: second factors use pquerna/otp, go-webauthn and boombuler/barcode, in sm-mfa-plugin only"; Date 2026-10-21; Status Accepted; Context Phase 19.6 D-01, D-04, D-06, D-08, D-14 and 19.6-RESEARCH.md Q1-Q3 and the Package Legitimacy Audit. Decision bullets: `github.com/pquerna/otp` v1.5.0 (published 2024-12-31, Apache-2.0, packages totp and hotp; issuer the app name, account the user's email, window plus or minus one step, SHA1, 6 digits, 30 s); `github.com/go-webauthn/webauthn` v0.18.2 (published 2026-09-19, BSD-3-Clause, packages webauthn and protocol; attestation none, timeout 60 s, user verification preferred for second-factor use and required for passkeys); `github.com/boombuler/barcode` v1.1.0 (MIT, package qr only; already required by pquerna/otp, so it adds no module) with a small SVG serializer in sm-mfa-plugin that keeps PHP's `{secret, qr_code_svg, qr_code_url}` contract (round-trip proven by an independent decoder; RESEARCH A1); the alternative piglig/go-qr v1.1.0 is rejected (67 stars, one maintainer, a new module for what boombuler already provides). Slow hash: bcrypt through `bouncer.HashPassword`/`CheckPassword` at `golem15.user.password.bcrypt_cost`, as the user plugin hashes passwords (no argon2; RESEARCH Q3, 631 ms per 10 compares). Measurements from RESEARCH Q1: a stripped demo binary grows by 2,269,184 bytes (+2.16 MiB, 51,802,375 to 54,071,559); newly linked modules boombuler/barcode v1.1.0, fxamacker/cbor/v2 v2.9.4, go-webauthn/webauthn v0.18.2, go-webauthn/x v0.3.1, google/go-tpm v0.9.8, philhofer/fwd v1.2.0, pquerna/otp v1.5.0, tinylib/msgp v1.6.4, x448/float16 v0.8.4; minimal version selection moves go-viper/mapstructure/v2 from v2.4.0 to v2.5.0 in the graphs of applications that mount the plugin; govulncheck v1.9.0 reports no reachable vulnerability (the module-level GO-2026-5932 sits in an unimported x/crypto package). go-webauthn's go.mod lists the archived YAML v3 module as an indirect, test-only requirement outside the import graph; plugin code never imports it (CLAUDE.md: goccy only). D-08 deviation recorded here: WebAuthn requests and responses use go-webauthn's standard base64url shapes, not PHP's lbuchs plain-base64 layout, because no client implements the PHP layout yet; PHP stored PEM public keys while go-webauthn verifies COSE keys, so the Phase 30 import must convert or drop WebAuthn rows. Package legitimacy: the seam cannot check Go modules, so the three are [ASSUMED] and gated by the blocking human check (plan 19.6-01 Task 5) before any `go get`. Consequences: only sm-mfa-plugin's go.mod requires them; sm-user-plugin, the framework and fonoteka.go require none; applications without the plugin link none of the nine modules (D-01); go-webauthn is pre-1.0 and the research is valid until 2026-11-20, so the executor re-checks the pin with `go list -m -json github.com/go-webauthn/webauthn@latest` before installing after that date. In .planning/research/STACK.md add three Supporting Libraries rows in the existing style (library with packages | version | purpose | "Decided in .planning/notes/2026-10-21-mfa-otp-webauthn.md (Phase 19.6 D-04); imported only by sm-mfa-plugin"). Commit the note and STACK.md together, alone: `docs(19.6): decide the OTP, WebAuthn and QR libraries`.
+
+Then the tracer in sm-user-plugin (D-02, RESEARCH Code Example 1 and Pattern 1). classes/login_gate.go: the types and constants in this plan's Decisions with doc comments: a gate inspects a first-factor sign-in that already succeeded; a nil result allows it; a non-nil result replaces the token response with its Status (0 means 200) and Body; an error must fail the sign-in closed; another plugin adds a gate in its Boot, because every Register runs before every Boot; Fields is the already-read login body (Pitfall 2) and is nil on social sign-in. `LoginGates` guards its slice with a sync.RWMutex; `Check` on a nil receiver returns nil, nil; it clones the slice under the read lock and returns the first non-nil result or the first error, never calling later gates after either. controllers/login_gates.go: `CheckLoginGates(ctx, app, a)` looks up `*classes.LoginGates` per call and returns nil, nil when none is published. plugin.go Register publishes `classes.NewLoginGates()` with `app.Publish` (keyed on its concrete pointer type, looked up the same way).
+
+classes/events.go adds `LoginEventName = "golem15.user.login"` and `LoginEvent{User *models.User; Source string}` (doc: fired after a sign-in issued a token: password login, a completed second factor, a passkey or a device hand-over; not on refresh). controllers/registration.go adds `FireLoginEvent(ctx, app, user, source)` modelled on FireDeactivatingEvent.
+
+Login (controllers/api_controller.go) per D-02 and D-18 A10: keep everything up to and including `CheckAndRecordLogin(ctx, gdb, user, ip, true)` unchanged; move the bcrypt rehash block directly after it (unchanged code); then `res, err := CheckLoginGates(ctx, app, classes.LoginAttempt{User: user, Source: classes.LoginSourcePassword, Fields: fields, Request: r})`: err → `writeOpaque500(w)` and return; res non-nil → `writeJSON(w, status, res.Body)` with status res.Status or 200 when it is 0, and return (no restore, no mail, no last_seen, no token, no event); then the soft-delete restore block moved here unchanged (with its reactivate mail), TouchLastSeen, mint, apiArray, then `FireLoginEvent(ctx, app, user, classes.LoginSourcePassword)` (a listener error is logged with the user id and never fails the login), then the unchanged final writeJSON. A comment cites D-02 and D-18 A10. Refresh is not touched in this task and never consults the gate.
+
+README (same commit as the code): Features gains "Login gates" (a plugin adds a classes.LoginGate to the published classes.LoginGates in its Boot; Login consults gates after the password and throttle checks and before restoring a deactivated account and minting; a gate answer replaces the token response; gate errors fail closed; refresh never consults gates) and "Login event"; the API reference gains rows for every identifier this task adds. Neutral wording ("a two-factor plugin").
+
+Smoke tests in login_gate_smoke_test.go. `TestLoginGatesSmoke` (no database, runs under -short): a nil *LoginGates returns nil, nil; an empty registry returns nil, nil; gates A (nil) then B (result) return B's result; a gate returning an error stops the chain before a following gate (a counter proves the later gate was not called). `TestLoginGateSmoke` (Postgres via sessionApp, skips under -short), subtests: `no_gate` (registry published and empty: login answers 200 with token and user and a LoginEvent listener records exactly one event with Source password); `challenge` (a fake gate returns Status 200 with body two_factor_required true, challenge_token of 64 x characters, available_methods ["totp"], expires_at "2026-10-21T12:00:00+00:00": the response body decodes to exactly that map with no token key, the gate saw Source password and Fields containing trusted_device_token sent in the login body, and no LoginEvent fired); `refusal` (Status 401 with {"error":"Invalid or expired challenge."} is answered as such); `gate_error` (opaque 500, no token key); `soft_deleted_stays` (a soft-deleted user with a challenging gate: deleted_at is still set afterwards and the captured mail has no reactivate message; with the registry emptied the same login restores the account and sends one reactivate mail, as before). If sessionApp does not run Register, the test publishes `classes.NewLoginGates()` itself. Commit in sm-user-plugin: `feat: login gates and the login event`.
+
+ 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 '^TestLoginGatesSmoke$' -v ./plugins/golem15/user 2>&1 | grep -q -- '^--- PASS: TestLoginGatesSmoke' && go -C ../fonoteka.go build ./... && go -C ../sm-sociallogin-plugin build ./... && go -C ../sm-oauthserver-plugin build ./...
+ Non-zero exit: vet or the short user-plugin suite is red, no "--- PASS: TestLoginGatesSmoke" line in the verbose run, or fonoteka.go, sm-sociallogin-plugin or sm-oauthserver-plugin (all three resolve sm-user-plugin to this checkout) no longer compile (a non-additive change).
+ go -C ../fonoteka.go test -count=1 -run '^(TestLoginGateSmoke|TestLogin|TestRefresh)$' -v ./plugins/golem15/user 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 3
+ The count of top-level "--- PASS: Test" lines is not 3: the gate smoke, the unchanged TestLogin or TestRefresh failed or skipped (a "--- SKIP" means Docker was unreachable: rerun through sudo -u golem, else record NOT RUN in the SUMMARY).
+
+
+ - `test -n "$(git log --diff-filter=A --format=%H -- .planning/notes/2026-10-21-mfa-otp-webauthn.md)"` exits 0, and the note contains each of `pquerna/otp v1.5.0`, `go-webauthn/webauthn v0.18.2`, `boombuler/barcode v1.1.0`, `2,269,184` and `go-viper/mapstructure` (one grep -c per literal, each at least 1).
+ - `grep -c '2026-10-21-mfa-otp-webauthn.md' .planning/research/STACK.md` prints at least 3.
+ - `grep -c 'type LoginGate interface' ../fonoteka.go/plugins/golem15/user/classes/login_gate.go` prints at least 1 and `grep -c 'CheckLoginGates(' ../fonoteka.go/plugins/golem15/user/controllers/api_controller.go` prints at least 1.
+ - `grep -c 'LoginEventName = "golem15.user.login"' ../fonoteka.go/plugins/golem15/user/classes/events.go` prints 1.
+ - `grep -cE 'LoginGates|LoginEvent|CheckLoginGates' ../fonoteka.go/plugins/golem15/user/README.md` prints at least 3.
+ - `D=$(git -C ../fonoteka.go/plugins/golem15/user diff --name-only 35cf166 -- updates) && test -z "$D"` exits 0 (no migration touched by this task).
+ - `test "$(grep -c 'pquerna\|webauthn\|boombuler' ../fonoteka.go/plugins/golem15/user/go.mod)" -eq 0` exits 0.
+
+ The libraries are on record before any code, and a password login can be held back by a registered gate without changing anything for applications that register none.
+
+
+
+ Task 2: Password changes announce themselves inside their transaction, and an administrator with the new permission gets a "Reset 2FA" action that appears only when a two-factor plugin publishes its service
+ D-07 and D-15: PasswordChangedEvent, TwoFactorAdmin and the golem15.users.reset_two_factor permission become API and backend-role data that other plugins and imported roles reference.
+ ../fonoteka.go/plugins/golem15/user/classes/events.go, ../fonoteka.go/plugins/golem15/user/classes/two_factor_admin.go, ../fonoteka.go/plugins/golem15/user/controllers/registration.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/controllers/users/config_form.yaml, ../fonoteka.go/plugins/golem15/user/admin_permissions.go, ../fonoteka.go/plugins/golem15/user/lang/en/lang.yaml, ../fonoteka.go/plugins/golem15/user/lang/pl/lang.yaml, ../fonoteka.go/plugins/golem15/user/password_changed_smoke_test.go, ../fonoteka.go/plugins/golem15/user/reset_two_factor_smoke_test.go, ../fonoteka.go/plugins/golem15/user/admin_registration_test.go, ../fonoteka.go/plugins/golem15/user/README.md
+ ../fonoteka.go/plugins/golem15/user/controllers/api_controller.go (ResetPassword :300-345, ChangePassword :560-600), ../fonoteka.go/plugins/golem15/user/controllers/users_admin_controller.go (:37-45, :215-320, :665-730), ../fonoteka.go/plugins/golem15/user/controllers/request_db.go, ../fonoteka.go/plugins/golem15/user/controllers/users/config_form.yaml, ../fonoteka.go/plugins/golem15/user/admin_permissions.go, ../fonoteka.go/plugins/golem15/user/classes/privileged.go, ../fonoteka.go/plugins/golem15/user/lang/en/lang.yaml (users and plugin sections), ../fonoteka.go/plugins/golem15/user/admin_harness_test.go (newAdminEnv, admin, call, expect), ../fonoteka.go/plugins/golem15/user/admin_users_test.go (TestAdminUserActions :575, TestAdminUserPassword :977), ../fonoteka.go/plugins/golem15/user/admin_registration_test.go (TestAdminRegistration :29: exact permission codes, the no-default-role rule, phrase keys in en and pl), ../fonoteka.go/plugins/golem15/user/deactivating_event_test.go, summercms.go modules/cabana/actions.go (:236-252 record-action permission and Applies handling), 19.6-RESEARCH.md (Q5 password-write sites, Q9, Pattern 5, Pitfall 3)
+ D-07 hook. classes/events.go adds `PasswordChangedEventName = "user.PasswordChanged"`, the three source constants and `PasswordChangedEvent{User *models.User; Tx *gorm.DB; Source string}` with the doc: fired inside the transaction that stores a new password the user or an administrator chose (reset, change, admin set), never for a rehash on login or a new account; a listener error rolls the write back; a two-factor plugin revokes trusted devices here. controllers/registration.go adds `FirePasswordChangedEvent(ctx, app, user, tx, source)` like FireDeactivatingEvent. ResetPassword and ChangePassword: wrap the existing `Updates(...)` and a `FirePasswordChangedEvent` call in one `gdb.WithContext(ctx).Transaction(func(tx *gorm.DB) error {...})`; the Updates map and every response body stay exactly as they are; a transaction error answers the opaque 500 the handler already uses for a failed write. Admin set (FormBeforeUpdate): when `changed` is true, after setting TokensValidAfter, call `FirePasswordChangedEvent(ctx, c.app(), user, requestDB(ctx, c.db), classes.PasswordChangeSourceAdmin)` (requestDB yields cabana's write transaction) and return its error so cabana rolls the save back. Never fire from the Login rehash or RegisterUser (Pitfall 3); a comment at the rehash says so.
+
+D-15 slot (RESEARCH Q9). classes/two_factor_admin.go: `TwoFactorAdmin` interface (methods in Artifacts) with a doc that a two-factor plugin publishes it with an explicit type argument (`app.Publish[classes.TwoFactorAdmin](svc)`), the users screen looks it up per request, and Reset disables every method, deletes WebAuthn credentials and recovery codes, revokes trusted devices and fires the plugin's disabled event, all on the given transaction. classes/privileged.go style constant `PermissionResetTwoFactor = "golem15.users.reset_two_factor"` in classes. admin_permissions.go adds the permission (Tab userPermissionTab, Label `golem15.user::lang.plugin.reset_two_factor`, no Roles; doc: only a superuser or a role granted it explicitly). config_form.yaml: `recordActions: [activate, unban, unsuspend, reset_two_factor]`. AdminRecordActions appends one action built like the existing closure but with `Permissions: []string{classes.PermissionResetTwoFactor}`, `Danger: true`, labels `reset_two_factor`, `reset_two_factor_confirm`, `reset_two_factor_success`; Applies looks up `classes.TwoFactorAdmin` through `c.app()` and returns false, nil when none is published (no two-factor plugin mounted), else `HasTwoFactor(ctx, requestDB(ctx, c.db), user.ID)`; Run takes `cabana.TxFromContext(ctx)` (error when missing, as the other actions) and calls `svc.Reset(ctx, tx, user)`; a missing service in Run returns an error, never a silent success. TestAdminRegistration pins the permission catalog: its `wantCodes` gains `golem15.users.reset_two_factor` after manage_privileged_groups and its no-default-role rule covers both codes without roles (a test update for an additive code, in this commit). Lang en and pl: `plugin.reset_two_factor` ("Reset two-factor authentication of users" / Polish), `users.reset_two_factor` ("Reset 2FA"), `users.reset_two_factor_confirm` (the user signs in with the password alone until they set up 2FA again), `users.reset_two_factor_success`.
+
+README (same commit): Features gains "Password-changed event" and "Admin: Reset 2FA" (shown only when a two-factor plugin publishes classes.TwoFactorAdmin; permission golem15.users.reset_two_factor without a default role); the Admin section lists the action; the API reference gains the new identifiers; the permissions list gains the code.
+
+Smoke tests. password_changed_smoke_test.go `TestPasswordChangedEventSmoke` (Postgres) with subtests: `reset` (a forgot/reset flow through the handler fires one event with Source reset), `change` (change-password fires one with Source change), `admin_set` (newAdminEnv: an admin update with a new password fires one with Source admin; an update without a password fires none), `rehash_fires_none` (set bcrypt_cost one above the stored hash's cost, log in, the stored hash changes and no event fired), `listener_error_rolls_back` (a listener returning an error makes change-password answer 500 and the stored hash is unchanged). reset_two_factor_smoke_test.go `TestResetTwoFactorSlotSmoke` (admin harness, Postgres): without a published TwoFactorAdmin the user's record response offers no reset_two_factor action; with a fake service whose HasTwoFactor is true and an admin holding golem15.users.access_users and golem15.users.reset_two_factor, the action is offered with danger true and running it calls Reset once with a non-nil transaction and the user's id; an admin holding only access_users is refused by cabana's permission check. Commit in sm-user-plugin: `feat: password-changed event and the reset-2FA admin slot`.
+
+ go -C ../fonoteka.go vet ./plugins/golem15/user/... && go -C ../fonoteka.go test -count=1 -short ./plugins/golem15/user/... && go -C ../fonoteka.go build ./... && go -C ../sm-sociallogin-plugin build ./... && go -C ../sm-oauthserver-plugin build ./...
+ Non-zero exit: vet or the short suite is red (cabana boot validation of the new record action or permission fails inside an admin test), or a dependent repository no longer compiles.
+ go -C ../fonoteka.go test -count=1 -run '^(TestPasswordChangedEventSmoke|TestResetTwoFactorSlotSmoke|TestAdminUserActions|TestAdminUserPassword|TestAdminRegistration)$' -v ./plugins/golem15/user 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 5
+ The count of top-level "--- PASS: Test" lines is not 5: a new smoke, an existing admin action or password test, or the permission-catalog test failed or skipped (Docker unreachable: rerun through sudo -u golem, else record NOT RUN).
+
+
+ - `grep -c 'type PasswordChangedEvent struct' ../fonoteka.go/plugins/golem15/user/classes/events.go` prints 1 and `grep -c 'FirePasswordChangedEvent(' ../fonoteka.go/plugins/golem15/user/controllers/api_controller.go` prints at least 2 and `grep -c 'FirePasswordChangedEvent(' ../fonoteka.go/plugins/golem15/user/controllers/users_admin_controller.go` prints at least 1.
+ - `grep -c 'reset_two_factor' ../fonoteka.go/plugins/golem15/user/controllers/users/config_form.yaml` prints 1 and `grep -c 'golem15.users.reset_two_factor' ../fonoteka.go/plugins/golem15/user/admin_permissions.go ../fonoteka.go/plugins/golem15/user/classes/*.go | awk -F: '{s+=$2} END {print s}'` prints at least 1.
+ - `grep -c 'Lookup\[classes.TwoFactorAdmin\]' ../fonoteka.go/plugins/golem15/user/controllers/users_admin_controller.go` prints at least 1.
+ - `grep -c 'reset_two_factor' ../fonoteka.go/plugins/golem15/user/lang/en/lang.yaml` prints at least 3 and the same count for lang/pl/lang.yaml is at least 3.
+ - `grep -cE 'PasswordChangedEvent|TwoFactorAdmin|reset_two_factor' ../fonoteka.go/plugins/golem15/user/README.md` prints at least 3.
+
+ Every user or administrator password change is announced inside its own write, and the Users screen can offer a two-factor reset that only appears where a two-factor plugin is mounted.
+
+
+
+ Task 3: A new device signs in by QR or short code: initiate shows a token, short code and auth_url, a signed-in user authorizes it, and the device's first poll receives a one-time JWT exactly once
+ D-11, D-16: the golem15_user_device_auth_sessions table and the five device routes become part of the core plugin's published schema and contract once plan 03 releases them; a later change needs a migration.
+ ../fonoteka.go/plugins/golem15/user/updates/202610210002_create_device_auth_sessions.go, ../fonoteka.go/plugins/golem15/user/models/device_auth_session.go, ../fonoteka.go/plugins/golem15/user/classes/device_auth.go, ../fonoteka.go/plugins/golem15/user/controllers/devices.go, ../fonoteka.go/plugins/golem15/user/routes.go, ../fonoteka.go/plugins/golem15/user/config/config.yaml, ../fonoteka.go/plugins/golem15/user/device_flow_smoke_test.go, ../fonoteka.go/plugins/golem15/user/README.md
+ ../fonoteka.go/plugins/golem15/user/updates/202610040001_create_api_tokens.go, ../fonoteka.go/plugins/golem15/user/updates/registry.go, ../fonoteka.go/plugins/golem15/user/models/api_token.go, ../fonoteka.go/plugins/golem15/user/models/registry.go, ../fonoteka.go/plugins/golem15/user/classes/api_tokens.go (:48-111), ../fonoteka.go/plugins/golem15/user/controllers/api_tokens.go, ../fonoteka.go/plugins/golem15/user/controllers/api_controller.go (authenticate :795-825, mintFor :877-884, requestURL, writeJSON), ../fonoteka.go/plugins/golem15/user/routes.go, ../fonoteka.go/plugins/golem15/user/config/config.yaml, ../fonoteka.go/plugins/golem15/user/updates/postgres_test.go (migration test harness), /media/nvme/dev/jz/wavepath.org/plugins/golem15/user/controllers/ApiController.php (:1233-1347), /media/nvme/dev/jz/wavepath.org/plugins/golem15/user/models/DeviceAuthSession.php, /media/nvme/dev/golem15/queststream.online/vue-queststream-app/composables/useDeviceAuth.ts, 19.6-RESEARCH.md (Q4 device flow and tables, Pattern 2, Pattern 4, Code Example 7, Pitfalls 13, 14, 16, 17)
+ Per D-03, D-06, D-11, D-12a, D-12c, D-16; additive only.
+
+Migration updates/202610210002_create_device_auth_sessions.go (raw SQL like the api-tokens migration, registered in init): `CREATE TABLE golem15_user_device_auth_sessions` with id BIGSERIAL PRIMARY KEY; token VARCHAR(64) NOT NULL UNIQUE (the SHA-256 hex of the 64-character token, D-06; PHP's column name, D-16); short_code VARCHAR(9) NULL UNIQUE; user_id INTEGER NULL REFERENCES users(id) ON DELETE CASCADE; status VARCHAR(16) NOT NULL DEFAULT 'pending' with a CHECK on the five PHP values; expires_at TIMESTAMPTZ NULL; device_ip VARCHAR(255) NULL; device_user_agent TEXT NULL; device_name VARCHAR(255) NULL; auth_ip VARCHAR(255) NULL; confirmed_at, used_at, last_activity_at TIMESTAMPTZ NULL; session_id VARCHAR(255) NULL (PHP column kept for the import); jwt_jti VARCHAR(64) NULL (D-12b); created_at and updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(); indexes on user_id, status, expires_at and jwt_jti. Rollback drops the table. models/device_auth_session.go: `DeviceAuthSession` with explicit column tags (TokenHash `gorm:"column:token"` and `json:"-"`), TableName, Fillable, Hidden as models/api_token.go, the five status constants, registered in init. If a user-plugin test counts models or migrations, update its expected count in the same commit with a comment naming this table.
+
+classes/device_auth.go: `DeviceShortCodeAlphabet = "23456789ABCDEFGHJKMNPQRSTUVWXYZ"`; `NewDeviceToken()` = 48 bytes from crypto/rand as base64.RawURLEncoding (64 characters); `NewDeviceShortCode()` = 8 characters drawn uniformly from the alphabet with crypto/rand (rejection sampling, no modulo bias), formatted XXXX-XXXX; `NormalizeDeviceShortCode(s)` uppercases, removes spaces and hyphens, requires exactly 8 alphabet characters and returns the XXXX-XXXX form (false otherwise); `DeviceAuthTTL(app)` from `golem15.user.device_auth.ttl_minutes` (default 5); `DeviceAuthURL(app, token)` replaces `{frontend_url}` (golem15.user.frontend_url trimmed of a trailing slash, else app.url) and `{token}` in `golem15.user.device_auth.auth_url` (default `{frontend_url}/device/scan/{token}`); `DeviceDisplayName(stored, userAgent)` ports PHP's accessor (:337-361). Hash tokens with the existing `HashAPIToken`. config/config.yaml documents the three new keys with comments (frontend_url empty means app.url; the template placeholders; the TTL).
+
+controllers/devices.go (handler factories taking *backpack.App, JSON through the plugin's writeJSON, timestamps through wire.Time so `+00:00` matches PHP's toIso8601String):
+- `DeviceInitiate` (unauthenticated): reads `device_name` from JSON or form, trims it and rejects more than 255 characters with 422 through writeValidation (Pitfall 14); first deletes at most 500 rows with status pending or expired whose expires_at is older than one hour (Pitfall 16; one bounded DELETE ... WHERE id IN (SELECT ... LIMIT 500)); generates token and short code, retrying the insert up to 5 times on a short_code unique violation; stores the token hash, status pending, expires_at now + TTL, device_ip `RemoteIP(r)`, device_user_agent; answers 200 `{"success":true,"data":{"token","short_code","auth_url","expires_at"}}`.
+- `DeviceStatus` (unauthenticated, GET devices/status/{token}): look up by HashAPIToken of the path value; unknown → 404 `{"error":"Session not found"}`; status pending or confirmed with expires_at in the past → one guarded `UPDATE ... SET status='expired' WHERE id=? AND status IN ('pending','confirmed')` and the flat body `{"success":true,"status":"expired","message":"Authorization session has expired"}`; status used, revoked or expired → `{"success":true,"data":{"status":}}` (used never becomes expired, Pitfall 13); pending → `{"success":true,"data":{"status":"pending"}}`; confirmed with a user → the D-12a hand-over (RESEARCH Code Example 7): load the user (not soft-deleted; if missing, mark the row revoked and answer status revoked), mint with `bouncer.Mint(secret, strconv.FormatUint(uint64(user.ID), 10), requestURL(r), ttl)` using the same ttl rule as mintFor (an unexported helper `mintWithJTI` beside mintFor), then `UPDATE golem15_user_device_auth_sessions SET status='used', used_at=NOW(), last_activity_at=NOW(), jwt_jti=?, updated_at=NOW() WHERE id=? AND status='confirmed' AND expires_at > NOW()`; RowsAffected 1 → `FinishLogin(ctx, app, gdb, user, RemoteIP(r))`, `FireLoginEvent(..., classes.LoginSourceDevice)` (logged on error) and 200 `{"success":true,"data":{"status":"confirmed","user_id":,"message":"Device authorized - complete login","token":,"user":}}`; RowsAffected 0 → the minted token is discarded unsent and the handler re-reads the row and answers its current status as above.
+- `DeviceAuthorize` (POST, bearer JWT checked inside the handler like `authenticate` but answering PHP's 401 `{"error":"Unauthorized"}` for a missing, invalid, expired, blacklisted or stale token; D-12c, no second-factor check): with a `short_code` field present, strip hyphens and require length 8 else 422 `{"error":"Please enter a valid 8-character code"}`, then match `NormalizeDeviceShortCode`; else with a `token` field match its hash; then one guarded `UPDATE ... SET status='confirmed', user_id=?, confirmed_at=NOW(), auth_ip=?, updated_at=NOW() WHERE id=? AND status='pending' AND (user_id IS NULL OR user_id=?) AND expires_at > NOW()`; RowsAffected 1, or the row already confirmed for this caller → 200 `{"success":true,"message":"Device authorized successfully"}`; nothing matched or not updatable → 400 `{"error":"Invalid or expired code"}`.
+routes.go: inside the existing user-api group add `g.Post("/devices/initiate", ...)`, `g.Get("/devices/status/{token}", ...)`, `g.Post("/devices/authorize", ...)` (no route middleware; authentication is in the handler).
+
+README (same commit): a "Device authorization" section (QR and short-code flow, the five statuses, the one-time token on the first poll after confirmation, the configurable auth_url, tokens stored hashed, PHP's response bodies), Configuration rows for the three keys, API reference rows. Smoke tests in device_flow_smoke_test.go: `TestDeviceCodesSmoke` (no database, -short: 1000 tokens are 64 base64url characters and distinct; short codes match `^[2-9A-HJKMNP-Z]{4}-[2-9A-HJKMNP-Z]{4}$`; normalize accepts lower case, spaces and a missing hyphen and refuses 7 or 9 characters and excluded letters such as O, I, L, 0 and 1; DeviceAuthURL with and without frontend_url and with a custom template). `TestDeviceFlowSmoke` (Postgres): initiate answers the data block with a 64-character token, a short code, an auth_url containing the token and an expires_at in `+00:00` form, and the stored token column equals HashAPIToken(token); status is pending; authorize with the lower-cased short code and a valid bearer answers success; the next status answers confirmed with a token that `bouncer.VerifyClaims` accepts for the authorizing user and stores its jti; the following status answers used without a token; 10 concurrent polls on another confirmed session yield exactly one token; authorize without a bearer answers 401 `{"error":"Unauthorized"}`; a session past expires_at answers the flat expired body. Commit in sm-user-plugin: `feat: device authorization by QR or short code`.
+
+ 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 '^TestDeviceCodesSmoke$' -v ./plugins/golem15/user 2>&1 | grep -q -- '^--- PASS: TestDeviceCodesSmoke' && go -C ../fonoteka.go build ./... && go -C ../sm-sociallogin-plugin build ./... && go -C ../sm-oauthserver-plugin build ./...
+ Non-zero exit: vet or the short suite is red, no "--- PASS: TestDeviceCodesSmoke" line, or a dependent repository no longer compiles.
+ go -C ../fonoteka.go test -count=1 -run '^(TestDeviceFlowSmoke|TestLoginGateSmoke)$' -v ./plugins/golem15/user/... 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 2
+ The count of top-level "--- PASS: Test" lines is not 2: the device flow or the gate smoke failed or skipped (Docker unreachable: rerun through sudo -u golem, else record NOT RUN).
+
+
+ - `grep -c 'golem15_user_device_auth_sessions' ../fonoteka.go/plugins/golem15/user/updates/202610210002_create_device_auth_sessions.go` prints at least 2 and `grep -c 'jwt_jti' ../fonoteka.go/plugins/golem15/user/updates/202610210002_create_device_auth_sessions.go` prints at least 1.
+ - `S=$(git -C ../fonoteka.go/plugins/golem15/user diff --name-status 35cf166 -- updates) && test "$(printf '%s\n' "$S" | grep -c '^[MDR]')" -eq 0` exits 0 (under updates/ only files were added).
+ - `grep -c "status = 'confirmed'" ../fonoteka.go/plugins/golem15/user/controllers/devices.go` prints at least 1 and `grep -c "status = 'pending'" ../fonoteka.go/plugins/golem15/user/controllers/devices.go` prints at least 1 (guarded transitions).
+ - `grep -c '"/devices/status/{token}"' ../fonoteka.go/plugins/golem15/user/routes.go` prints 1 and `grep -c '"/devices/initiate"' ../fonoteka.go/plugins/golem15/user/routes.go` prints 1.
+ - `grep -c 'device_auth' ../fonoteka.go/plugins/golem15/user/config/config.yaml` prints at least 1 and `grep -c 'frontend_url' ../fonoteka.go/plugins/golem15/user/config/config.yaml` prints at least 1.
+ - `grep -c 'Device authorization' ../fonoteka.go/plugins/golem15/user/README.md` prints at least 1.
+
+ A television or desktop app can show a QR or short code, a signed-in user approves it from another device, and the waiting device receives one token exactly once.
+
+
+
+ Task 4: A user lists the devices they signed in and revokes one, and the revoked device is signed out at once, also after it refreshed its token
+ ../fonoteka.go/plugins/golem15/user/controllers/devices.go, ../fonoteka.go/plugins/golem15/user/controllers/api_controller.go, ../fonoteka.go/plugins/golem15/user/classes/device_auth.go, ../fonoteka.go/plugins/golem15/user/routes.go, ../fonoteka.go/plugins/golem15/user/device_revoke_smoke_test.go, ../fonoteka.go/plugins/golem15/user/README.md
+ ../fonoteka.go/plugins/golem15/user/controllers/devices.go and classes/device_auth.go (Task 3), ../fonoteka.go/plugins/golem15/user/controllers/api_controller.go (Logout blacklist call, Refresh :179-213, refreshTTL, blacklistGrace), ../fonoteka.go/plugins/golem15/user/controllers/api_tokens.go (List and Destroy), summercms.go modules/bouncer/refresh.go (:92-155), modules/bouncer/blacklist.go, /media/nvme/dev/jz/wavepath.org/plugins/golem15/user/controllers/ApiController.php (:1175-1227), 19.6-RESEARCH.md (Q7, Pattern 4, Code Example 8, Pitfalls 15, 17)
+ Per D-12b and D-11. `DeviceIndex` (GET devices, bearer checked in the handler with PHP's 401 body): rows of the caller with status confirmed or used ordered by last_activity_at DESC NULLS LAST, then id DESC; each item `{id, device_name: DeviceDisplayName(row.DeviceName, row.DeviceUserAgent), device_ip, authorized_at: confirmed_at as wire.Time or null, last_activity: last_activity_at as wire.Time or null, is_current: row.jwt_jti equals the bearer token's jti}`; answer `{"success":true,"devices":[...]}` with wire.Slice so an empty list is `[]`. `DeviceDestroy` (DELETE devices/{id}): parse the id, find the row scoped by `user_id` of the caller (else 404 `{"error":"Device not found"}`); in one transaction set status revoked and updated_at with a guarded UPDATE scoped by id and user_id, and when the row has a jwt_jti add it to the published `bouncer.BlacklistStore` with expiresAt = now + refreshTTL + the JWT ttl + 1 minute and validUntil = now (so the token and every refresh of it are refused at once; RESEARCH Q7); a blacklist error answers the opaque 500 and rolls the status back; success answers `{"success":true,"message":"Device revoked successfully"}`. Revoking an already revoked row answers the same success (PHP's revoke() saved unconditionally). routes.go: `g.Get("/devices", ...)` and `g.Delete("/devices/{id}", ...)` immediately followed by `g.Where("id", "[0-9]+")` (surf binds Where to the last route, Pitfall 17).
+
+Refresh JTI hand-over (RESEARCH Q7, Code Example 8). classes/device_auth.go `HandOverDeviceJTI(ctx, db, oldJTI, newJTI)` runs `UPDATE golem15_user_device_auth_sessions SET jwt_jti = ?, last_activity_at = NOW(), updated_at = NOW() WHERE jwt_jti = ? AND status = 'used'` and does nothing for empty values. In `Refresh`, after `bouncer.Refresh` succeeded: read the old token's jti from the raw token's payload segment with the standard library (split on dots, base64.RawURLEncoding decode of the middle part, JSON field jti; safe because bouncer.Refresh has just verified the signature), read the new jti from `bouncer.VerifyClaims(next, secret)` (the block that already parses it for TouchLastSeen), and call HandOverDeviceJTI; an error is logged with no token or jti value and never fails the refresh; the response body stays `{"token": next}`. A comment documents Pitfall 15: with golem15.user.jwt.blacklist_grace above 0 two concurrent refreshes of one token can fork a device's lineage and only one fork is tracked; a password change still ends both through tokens_valid_after.
+
+README (same commit): the Device authorization section gains the list and revoke routes, is_current, the immediate sign-out on revoke including refreshed tokens, and the blacklist_grace caveat; API reference row for HandOverDeviceJTI. Smoke test device_revoke_smoke_test.go `TestDeviceRevokeSmoke` (Postgres) with subtests: `list` (two used sessions and one pending of the caller plus one of another user: the list has the caller's two, newest activity first, device_name from the stored name or the parsed user agent, is_current true only for the session whose token is the bearer); `revoke` (DELETE answers success; GET /fetch with the device token answers 401; DELETE of another user's id answers 404; DELETE /devices/abc does not match the route); `revoke_after_refresh` (the device refreshes its token, the row's jwt_jti now equals the new jti, revoke, then both the old and the refreshed token answer 401 on /fetch and refreshing the refreshed token fails). Commit in sm-user-plugin: `feat: device list and revoke with the jti followed through refresh`.
+
+ go -C ../fonoteka.go vet ./plugins/golem15/user/... && go -C ../fonoteka.go test -count=1 -short ./plugins/golem15/user/... && go -C ../fonoteka.go build ./... && go -C ../sm-sociallogin-plugin build ./... && go -C ../sm-oauthserver-plugin build ./...
+ Non-zero exit: vet or the short suite is red, or a dependent repository no longer compiles.
+ go -C ../fonoteka.go test -count=1 -run '^(TestDeviceRevokeSmoke|TestDeviceFlowSmoke|TestRefresh|TestLogout)$' -v ./plugins/golem15/user 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 4
+ The count of top-level "--- PASS: Test" lines is not 4: revoke, the device flow, or the unchanged refresh or logout tests failed or skipped (Docker unreachable: rerun through sudo -u golem, else record NOT RUN).
+
+
+ - `grep -c 'HandOverDeviceJTI(' ../fonoteka.go/plugins/golem15/user/controllers/api_controller.go` prints at least 1.
+ - `grep -c 'Lookup\[bouncer.BlacklistStore\]' ../fonoteka.go/plugins/golem15/user/controllers/devices.go` prints at least 1.
+ - `grep -A1 '"/devices/{id}"' ../fonoteka.go/plugins/golem15/user/routes.go | grep -c 'Where("id", "\[0-9\]+")'` prints 1 (the constraint follows the route directly).
+ - `grep -c '"Device revoked successfully"' ../fonoteka.go/plugins/golem15/user/controllers/devices.go` prints 1 and `grep -c '"Device not found"' ../fonoteka.go/plugins/golem15/user/controllers/devices.go` prints 1.
+ - `grep -c 'blacklist_grace' ../fonoteka.go/plugins/golem15/user/README.md` prints at least 1.
+ - `test -z "$(git -C ../fonoteka.go/plugins/golem15/user status --porcelain)"` exits 0 after the commit, and `C=$(git -C ../fonoteka.go/plugins/golem15/user rev-list --count 35cf166..HEAD) && test "$C" -ge 4` exits 0.
+
+ A user sees which devices they signed in, and revoking one ends its session immediately, even if it refreshed its token in the meantime.
+
+
+
+ Task 5: Package legitimacy check before installing github.com/pquerna/otp v1.5.0, github.com/go-webauthn/webauthn v0.18.2 and github.com/boombuler/barcode v1.1.0 (runtime dependencies of sm-mfa-plugin only)
+ .planning/notes/2026-10-21-mfa-otp-webauthn.md, 19.6-RESEARCH.md (Q1, Q2, Package Legitimacy Audit, Assumption A13)
+
+ - The SUMMARY records the human's reply verbatim ("approved" or "rejected") under a "Task 5 reply" heading.
+ - Until the reply, `test ! -e ../sm-mfa-plugin/go.mod` exits 0 (plan 02 has not started), and `grep -c 'go-webauthn' go.mod ../fonoteka.go/plugins/golem15/user/go.mod ../fonoteka.go/go.mod | awk -F: '{s+=$2} END {print s}'` prints 0.
+
+ Nothing installed. Tasks 1-4 are committed locally: the decision note naming the three libraries, and sm-user-plugin's login gates, login and password-changed events, reset-2FA slot and device authorization flow. RESEARCH verified the three modules on proxy.golang.org (pquerna/otp v1.5.0 published 2024-12-31, Apache-2.0, 2,963 stars; go-webauthn/webauthn v0.18.2 published 2026-09-19, BSD-3-Clause, 1,351 stars, pushed 2026-10-20; boombuler/barcode v1.1.0 published 2025-07-22, MIT, 1,563 stars and already a requirement of pquerna/otp), measured the +2.16 MiB binary delta and the nine newly linked modules, and ran govulncheck (clean). The package-legitimacy seam cannot check Go modules, so all three carry [ASSUMED] and need your check (19.4 and 19.5 precedent). They will be required only by sm-mfa-plugin and appear in the module graph of applications that mount it (sm-demo-app, sm-bm-app). Never auto-approved.
+
+ 1. Open https://pkg.go.dev/github.com/pquerna/otp, https://pkg.go.dev/github.com/go-webauthn/webauthn and https://pkg.go.dev/github.com/boombuler/barcode and their GitHub repositories: confirm the owners, the licences and that v1.5.0, v0.18.2 and v1.1.0 are tagged releases.
+ 2. In a scratch directory run `go list -m -json github.com/go-webauthn/webauthn@v0.18.2` (and the same for the other two) and confirm Origin.URL points at the GitHub repository and Origin.Ref at the tag.
+ 3. Confirm the three are acceptable as runtime dependencies of sm-mfa-plugin only (never in the framework, sm-user-plugin or fonoteka.go).
+
+ Reply "approved" to let plan 02 run `go get` for the three pins in sm-mfa-plugin, or "rejected" to stop: plan 02 must then not start, because D-04 locks pquerna/otp and go-webauthn, and the phase returns to /msd-discuss-phase 19.6.
+
+
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+|----------|-------------|
+| first-factor login to token mint | a correct password must not yield a token while a second factor is pending |
+| sm-user-plugin to other plugins | gates, the admin reset service and event listeners run code from sm-mfa-plugin inside the user plugin's requests and transactions |
+| unauthenticated device to devices/initiate and devices/status | anyone can create sessions and poll with a token |
+| signed-in user to devices/authorize, list and revoke | a bearer JWT binds or ends device sessions |
+| JWT refresh to device revocation | a refreshed device token must stay revocable |
+| administrator to the Users screen | a privileged action removes a user's second factors |
+
+## STRIDE Threat Register
+
+| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
+|-----------|----------|-----------|----------|-------------|-----------------|
+| T-19.6-01 | Elevation of privilege | Login gate consultation (controllers/api_controller.go, CheckLoginGates) | high | mitigate | gate errors answer the opaque 500 and never fall through to the mint; a non-nil result returns before restore, last_seen, mint and event; TestLoginGateSmoke subtests challenge, refusal and gate_error |
+| T-19.6-02 | Tampering | soft-delete restore before the second factor (Pitfall 21, D-18 A10) | medium | mitigate | the restore and reactivate mail run after the gate; TestLoginGateSmoke soft_deleted_stays |
+| T-19.6-03 | Spoofing | trusted devices surviving a password change (D-07) | medium | mitigate | PasswordChangedEvent fires inside the password-write transaction from reset, change and admin set, a listener error rolls the write back, the rehash fires none; TestPasswordChangedEventSmoke |
+| T-19.6-04 | Elevation of privilege | reset_two_factor record action (D-15) | medium | mitigate | new permission without a default role, Danger confirm, cabana's permission check and audit log, Applies false without a published service, Run errors without one; TestResetTwoFactorSlotSmoke |
+| T-19.6-05 | Tampering | short-code guessing to bind a victim's pending device | medium | mitigate | 31^8 code space from crypto/rand without modulo bias, 5-minute TTL, user-api bucket, guarded pending-to-confirmed UPDATE that requires user_id NULL or the caller |
+| T-19.6-06 | Spoofing | device-code phishing (attacker shows its QR to a victim who approves it) | medium | accept | D-12c keeps PHP's authorize-with-JWT rule; the device list shows name and IP and revoke signs the device out at once; the frontend is expected to show device name and IP before confirming |
+| T-19.6-07 | Elevation of privilege | device hand-over race (two polls, two tokens) | high | mitigate | single guarded confirmed-to-used UPDATE; a poll that loses never sends its minted token; TestDeviceFlowSmoke 10 concurrent polls yield one token |
+| T-19.6-08 | Elevation of privilege | revoked device keeps refreshing (RESEARCH Q7) | high | mitigate | Refresh hands the row's jwt_jti to the new token; revoke blacklists the current jti for the refresh window; TestDeviceRevokeSmoke revoke_after_refresh; blacklist_grace fork documented (Pitfall 15) |
+| T-19.6-09 | Information disclosure | device tokens at rest and in URLs | medium | mitigate | only the SHA-256 hex is stored (D-06); the framework access log records no paths; reverse-proxy logging of devices/status/{token} is documented in the README; tokens live 5 minutes and are one-time |
+| T-19.6-10 | Denial of service | unauthenticated devices/initiate filling the table (Pitfall 16) | low | mitigate | user-api bucket (120/min) and a bounded cleanup of stale pending and expired rows on each initiate |
+| T-19.6-11 | Tampering | attacker-chosen device_name (Pitfall 14) | low | mitigate | trimmed, max 255 with a 422, rendered by clients as text; the parsed user agent and IP shown beside it |
+| T-19.6-12 | Tampering | core plugin contract (19.4 D-05, CLAUDE.md) | medium | mitigate | only added exports, routes, config keys and one added migration; TestLogin, TestRefresh, TestLogout and the admin tests pass unchanged; fonoteka.go, sm-sociallogin-plugin and sm-oauthserver-plugin compile in every verify |
+| T-19.6-SC | Tampering | Go module installs | high | mitigate | no module is installed in this plan; the three pins wait for the Task 5 blocking human check (never auto-approved); acceptance criteria grep go.mod files for go-webauthn |
+
+
+
+- Task 1: the note precedes the code; TestLoginGatesSmoke (short), TestLoginGateSmoke, TestLogin and TestRefresh; dependent repositories compile.
+- Task 2: TestPasswordChangedEventSmoke, TestResetTwoFactorSlotSmoke, TestAdminUserActions, TestAdminUserPassword.
+- Task 3: TestDeviceCodesSmoke (short), TestDeviceFlowSmoke; only added files under updates/.
+- Task 4: TestDeviceRevokeSmoke, TestDeviceFlowSmoke, TestRefresh, TestLogout.
+- Task 5: the human reply recorded; no go.mod requires the libraries before it.
+- Feedback sampling (19.6-VALIDATION.md): after each commit `go -C ../fonoteka.go vet ./plugins/golem15/user/... && go -C ../fonoteka.go test -short ./plugins/golem15/user/...`.
+
+
+
+- D-04: decision note on record before any library code; libraries gated by a human check.
+- D-02 and D-18 A10: Login consults gates after the password step and before restore and mint; unchanged without gates.
+- D-07 and D-15: password-changed event from three sites; reset slot offered only with a published service.
+- D-03, D-11, D-12a, D-12b, D-12c, D-16: device flow with PHP's paths and bodies, one-time JWT, revoke across refresh.
+- Additive: every v0.1.5 test passes, dependent repositories compile, nothing pushed.
+
+
+
diff --git a/.planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-02-PLAN.md b/.planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-02-PLAN.md
new file mode 100644
index 00000000..efa90cc7
--- /dev/null
+++ b/.planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-02-PLAN.md
@@ -0,0 +1,356 @@
+---
+phase: 19.6-two-factor-auth-and-device-flow-in-sm-user-plugin
+plan: 02
+type: execute
+wave: 2
+depends_on: ["19.6-01"]
+files_modified:
+ - ../sm-mfa-plugin/.gitignore
+ - ../sm-mfa-plugin/go.mod
+ - ../sm-mfa-plugin/go.sum
+ - ../sm-mfa-plugin/go.work
+ - ../sm-mfa-plugin/plugin.go
+ - ../sm-mfa-plugin/routes.go
+ - ../sm-mfa-plugin/buckets.go
+ - ../sm-mfa-plugin/mail.go
+ - ../sm-mfa-plugin/admin_settings.go
+ - ../sm-mfa-plugin/admin_permissions.go
+ - ../sm-mfa-plugin/config/config.yaml
+ - ../sm-mfa-plugin/lang/en/lang.yaml
+ - ../sm-mfa-plugin/lang/pl/lang.yaml
+ - ../sm-mfa-plugin/views/mail/two_factor_code.htm
+ - ../sm-mfa-plugin/views/mail/two_factor_code-en.htm
+ - ../sm-mfa-plugin/classes/config.go
+ - ../sm-mfa-plugin/classes/gate.go
+ - ../sm-mfa-plugin/classes/challenge.go
+ - ../sm-mfa-plugin/classes/login.go
+ - ../sm-mfa-plugin/classes/totp.go
+ - ../sm-mfa-plugin/classes/qr.go
+ - ../sm-mfa-plugin/classes/recovery.go
+ - ../sm-mfa-plugin/classes/email.go
+ - ../sm-mfa-plugin/classes/trusted.go
+ - ../sm-mfa-plugin/classes/methods.go
+ - ../sm-mfa-plugin/classes/events.go
+ - ../sm-mfa-plugin/classes/admin.go
+ - ../sm-mfa-plugin/controllers/helpers.go
+ - ../sm-mfa-plugin/controllers/challenge.go
+ - ../sm-mfa-plugin/controllers/management.go
+ - ../sm-mfa-plugin/models/registry.go
+ - ../sm-mfa-plugin/models/two_factor_method.go
+ - ../sm-mfa-plugin/models/two_factor_challenge.go
+ - ../sm-mfa-plugin/models/two_factor_recovery_code.go
+ - ../sm-mfa-plugin/models/trusted_device.go
+ - ../sm-mfa-plugin/models/settings.go
+ - ../sm-mfa-plugin/models/string_list.go
+ - ../sm-mfa-plugin/models/settings/fields.yaml
+ - ../sm-mfa-plugin/updates/registry.go
+ - ../sm-mfa-plugin/updates/202610220001_create_golem15_user_two_factor_methods.go
+ - ../sm-mfa-plugin/updates/202610220002_create_golem15_user_two_factor_challenges.go
+ - ../sm-mfa-plugin/updates/202610220003_create_golem15_user_two_factor_recovery_codes.go
+ - ../sm-mfa-plugin/updates/202610220004_create_golem15_user_trusted_devices.go
+ - ../sm-mfa-plugin/updates/202610220005_create_golem15_mfa_settings.go
+ - ../sm-mfa-plugin/testmain_test.go
+ - ../sm-mfa-plugin/harness_test.go
+ - ../sm-mfa-plugin/tracer_smoke_test.go
+ - ../sm-mfa-plugin/challenge_smoke_test.go
+ - ../sm-mfa-plugin/trusted_settings_smoke_test.go
+ - ../sm-mfa-plugin/README.md
+autonomous: true
+requirements: [SC-1, SC-2, SC-4, D-01, D-04, D-05, D-06, D-07, D-09, D-10, D-12, D-13, D-15, D-16, D-18, D-19]
+estimate:
+ tokens: 340000
+ raw_tokens: 340000
+ tasks: 3
+ confidence: low
+must_haves:
+ truths:
+ - "D-01: a new local repository sm-mfa-plugin (module git.golem15.com/golem15/sm-mfa-plugin, package mfa, plugin ID golem15.mfa) requires golem15.user; only its go.mod requires pquerna/otp v1.5.0 and boombuler/barcode v1.1.0 (go-webauthn arrives in plan 03); its Boot needs no database, cache or mailer."
+ - "SC-2, D-02, D-05: with the plugin mounted and TOTP enabled, POST /_user/api/v1/login answers 200 {two_factor_required:true, challenge_token (64 characters), available_methods (the user's enabled methods, unfiltered by the global list), expires_at (+00:00)} and no token; the challenge routes /_user/api/v1/2fa/{verify,recovery,email/send} are unauthenticated and share the 2fa-verify bucket of 10 requests per minute per IP (the 11th answers 429)."
+ - "D-05: POST 2fa/verify, 2fa/recovery and 2fa/email/send answer PHP's validation 422 bodies, PHP's 401 bodies ({\"error\":\"Invalid verification code.\",\"two_factor_failed\":true}, {\"error\":\"Invalid recovery code.\",\"two_factor_failed\":true}, {\"error\":\"Invalid or expired challenge.\"}) and on success {token, user} from the user plugin's helpers, followed by the login event and golem15.user.2fa.verified; a challenge lives two_factor_challenge_ttl minutes (5), allows five verifications (each verify increments attempts in one guarded UPDATE, recorded deviation A5) and completes at most once, so parallel verifies mint at most one token."
+ - "D-04, D-05: POST 2fa/totp/setup answers {secret, qr_code_svg, qr_code_url} (otpauth URL with the app name as issuer and the email as account; SVG 200x200 with a viewBox, drawn from boombuler/barcode's matrix); totp/confirm verifies with a one-step window and stores the secret encrypted (lagoon.Encrypted); a TOTP code is accepted once per time step (last counter in metadata, Pitfall 5); DELETE totp disables it."
+ - "D-05, D-19: recovery codes come in sets of 10 in the form XXXXX-XXXXX over 23456789ABCDEFGHJKMNPQRSTUVWXYZ, stored as bcrypt hashes and consumed once through a guarded UPDATE; GET recovery-codes?password= answers only the remaining count; POST recovery-codes/regenerate replaces the set in one transaction and returns the new codes; when totp/confirm or email/enable creates the first set (no unused code existed), the response adds recovery_codes, otherwise it is PHP's body unchanged."
+ - "D-05, D-18 (A8, A9): the email code is 6 digits stored as a bcrypt hash and mailed through golem15.mfa::mail.two_factor_code (-en for English) with name, code and expiry_minutes = min(two_factor_email_code_ttl, remaining challenge minutes); email/send and verify method=email work only when the user's email method is enabled (else 401 {\"error\":\"Invalid or expired challenge.\"}) and at most 3 sends per challenge."
+ - "D-18 (A6): after 10 failed second-factor verifications of one user within 15 minutes, counted from challenge rows in the database, new challenges are refused at login with 401 {\"error\":\"Invalid or expired challenge.\"} and every verify endpoint answers its generic 401, until the window passes; failed challenge rows are kept for the window."
+ - "D-06: challenge and trusted-device tokens are stored as SHA-256 hex in PHP's token columns, the TOTP secret as lagoon.Encrypted ciphertext, recovery and email codes as bcrypt hashes; no token, code or secret is logged."
+ - "D-07, D-10: with trusted_device_enabled on, a verify with trust_device adds trusted_device_token, a login sending it skips the challenge until trusted_device_ttl_days (30) pass; any PasswordChangedEvent and disabling the user's last method delete the user's trusted devices (the latter also the recovery codes, as PHP); DELETE trusted-devices/{id} and DELETE trusted-devices revoke them."
+ - "D-12, D-13, D-09 (setting): an admin Settings screen (code mfa, permission golem15.mfa.access_settings) holds two_factor_mode (disabled|optional|enforced, default optional), two_factor_available_methods (default [totp,email], allow-list totp,email,webauthn), two_factor_enforce_groups (existing group codes), two_factor_email_code_ttl 10, two_factor_challenge_ttl 5, two_factor_passwordless_login off, trusted_device_enabled off, trusted_device_ttl_days 30 and two_factor_social_login off; config supplies each default; a saved row applies on the next request; only mode disabled exempts a user (no child exemption)."
+ - "D-15: the plugin publishes userclasses.TwoFactorAdmin; the user plugin's reset_two_factor action then disables every method, deletes recovery codes and trusted devices and fires golem15.user.2fa.disabled inside cabana's transaction."
+ - "D-16: migrations create golem15_user_two_factor_methods, golem15_user_two_factor_challenges, golem15_user_two_factor_recovery_codes and golem15_user_trusted_devices with PHP's names and columns (plus the challenge email_sends counter) and golem15_mfa_settings with its seeded row 1."
+ artifacts:
+ - path: "../sm-mfa-plugin/plugin.go"
+ provides: "golem15.mfa plugin, gate registration, published TwoFactorAdmin, listeners"
+ contains: "golem15.mfa"
+ - path: "../sm-mfa-plugin/classes/challenge.go"
+ provides: "challenge creation and guarded attempt and completion UPDATEs"
+ contains: "attempts < max_attempts"
+ - path: "../sm-mfa-plugin/classes/qr.go"
+ provides: "QRSVG over boombuler/barcode"
+ contains: "viewBox"
+ - path: "../sm-mfa-plugin/buckets.go"
+ provides: "2fa-verify bucket"
+ contains: "2fa-verify"
+ - path: "../sm-mfa-plugin/tracer_smoke_test.go"
+ provides: "TestTracerSmoke and TestQRSVGSmoke"
+ contains: "TestTracerSmoke"
+ key_links:
+ - from: "../sm-mfa-plugin/plugin.go"
+ to: "../fonoteka.go/plugins/golem15/user/classes/login_gate.go"
+ via: "Boot adds the mfa gate to the published *userclasses.LoginGates"
+ pattern: "LoginGates"
+ - from: "../sm-mfa-plugin/classes/login.go"
+ to: "../fonoteka.go/plugins/golem15/user/controllers/registration.go"
+ via: "FinishLogin, IssueToken, APIArray and FireLoginEvent after a verified factor"
+ pattern: "FireLoginEvent"
+ - from: "../sm-mfa-plugin/plugin.go"
+ to: "../fonoteka.go/plugins/golem15/user/classes/events.go"
+ via: "PasswordChangedEvent listener deletes trusted devices"
+ pattern: "Listen\\[\\*userclasses.PasswordChangedEvent\\]"
+ - from: "../sm-mfa-plugin/routes.go"
+ to: "../sm-mfa-plugin/buckets.go"
+ via: "challenge group uses throttle:2fa-verify"
+ pattern: "throttle:2fa-verify"
+ prohibitions:
+ - statement: "No token, code, TOTP secret, password or recovery code value is written to a log line or an error string."
+ status: unresolved
+ verification: judgment
+ - statement: "YAML is parsed with goccy/go-yaml only; the archived YAML v3 module is never imported by plugin code."
+ status: unresolved
+ verification: judgment
+ - statement: "No notification mail on 2FA enable or disable, no per-group exemption and no Winter import (CONTEXT Deferred Ideas); no child-account exemption (D-13)."
+ status: unresolved
+ verification: judgment
+ - statement: "Nothing is pushed and no remote repository is created in this plan (plan 03 asks for the go-ahead)."
+ status: unresolved
+ verification: judgment
+ - statement: "sm-user-plugin is not changed by this plan except to fix a defect a task exposes, in its own commit with its README change, recorded as a deviation."
+ status: unresolved
+ verification: judgment
+---
+
+## Phase Goal
+
+ROADMAP Goal (not in user-story format; no story invented, see plan 01): `sm-user-plugin` has the PHP user plugin's second factors and device login. CONTEXT D-01 puts the second factors in the new shared plugin `sm-mfa-plugin`, which requires the user plugin and registers itself on plan 01's login gate.
+
+This plan's slice (plan 2 of 4, wave 2, "sm-mfa-plugin core"): the whole plugin except WebAuthn. The tracer proves the TOTP path end to end: a user turns TOTP on, the next login is challenged, and the TOTP code completes it. Expansion slices add recovery codes, email codes and the D-18 hardening, then trusted devices, the admin settings screen, revocation on password change and the admin reset service.
+
+## 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-20.
+
+## Decisions taken in this plan (recorded assumptions and Claude's discretion)
+
+- Layout copies sm-sociallogin-plugin: package `mfa` at the root (plugin.go, routes.go, buckets.go, mail.go, admin_settings.go, admin_permissions.go), `classes`, `controllers`, `models` (with `models/settings/fields.yaml`), `updates`, `config`, `lang`, `views/mail`; README in sociallogin's structure.
+- Config keys use PHP's setting names under `golem15.mfa.`: `two_factor_mode`, `two_factor_available_methods`, `two_factor_enforce_groups`, `two_factor_email_code_ttl`, `two_factor_challenge_ttl`, `two_factor_passwordless_login`, `trusted_device_enabled`, `trusted_device_ttl_days`, `two_factor_social_login` (new, D-09), plus `issuer` (default app.name), `failure_cap.max` (10) and `failure_cap.window_minutes` (15) for D-18 A6. The settings row (table `golem15_mfa_settings`, nullable columns, NULL means the config default) overrides per request.
+- Settings field types per RESEARCH Q8: `dropdown` for the mode, `taglist` with `mode: array` on a JSONB column (type `models.StringList` with sql.Scanner and driver.Valuer) plus `BeforeValidate` allow-lists for methods (totp, email, webauthn) and enforce groups (existing group codes), `number`, `switch`. Never checkboxlist or radio (Pitfall 19).
+- The mode is a typed string with an allow-list; an unknown stored value behaves as `optional` and is refused by validation (the PHP USER-005 type confusion, mapped in plan 04).
+- Management routes carry `jwt.auth` per route as PHP's group does, and sit in a group with `throttle:user-api` (hardening beyond PHP: the password-confirming recovery-code routes otherwise have no rate limit); PHP's 401 `{"error":"Unauthorized"}` is the handler's fallback when no principal resolves.
+- Five real attempts per challenge (RESEARCH A5; PHP's increment-then-check gave four). The response shapes are identical either way.
+- Per-user failure cap (D-18 A6): failures in the window = the sum over the user's challenge rows updated within `failure_cap.window_minutes` of `attempts` for uncompleted rows and `attempts - 1` for completed ones; at `failure_cap.max` or more the gate refuses with Status 401 and the verify endpoints answer their generic 401. Creating a challenge deletes only the user's rows whose expires_at is older than the window, so failures stay counted (PHP deleted expired rows eagerly).
+- The email send counter is a new column `email_sends SMALLINT NOT NULL DEFAULT 0` on the challenge table (D-18 A8 needs it; D-16's PHP columns are otherwise unchanged).
+- `available_methods` in a challenge lists the user's enabled methods, dropping webauthn when no enabled credential exists (PHP), unfiltered by the global list. Status `enforced` is informational as in PHP (RESEARCH Open Question 2): mode enforced and either no enforce groups or a shared group code.
+- Recovery codes are always accepted through `/2fa/recovery`, whatever the available list says (D-05). Recovery input is uppercased, spaces removed, hyphen kept, and capped at 32 characters before any bcrypt compare (Pitfall 9).
+- The mail template has no layout header (postcard's default layout; the user plugin's `user` alias is not visible to another plugin, Pitfall 18); vars are passed in both cases (`name`/`Name`, `code`/`Code`, `expiry_minutes`/`ExpiryMinutes`).
+- Admin reset clears each method's secret and metadata as it disables it (PHP left them; a reset must not let an old secret return when the method is re-enabled).
+- Migration IDs `202610220001`..`202610220005` in this plan and `202610220006` (WebAuthn credentials) in plan 03; they may still change until plan 03's release, after which they are frozen.
+- The go.mod requires `git.golem15.com/golem15/sm-user-plugin v0.1.5` and the framework tag sm-sociallogin-plugin requires, with go.work replacing both by the local checkouts (sociallogin precedent); plan 03 moves the user-plugin require to the released tag.
+- Context budget (estimate 340k, 3 tasks; accepted under the project's lean-planning rule): if the first context runs low after Task 2, the executor writes `19.6-02-SUMMARY.md` as a draft (`Status: draft (hand-off after Task 2)`) and a fresh agent runs Task 3 from this plan, the draft and the repository on disk.
+
+## Artifacts this phase produces (this plan)
+
+- Repository `../sm-mfa-plugin` (branch master, no remote until plan 03), module `git.golem15.com/golem15/sm-mfa-plugin`, package `mfa`.
+- Plugin: `mfa.PluginID` (`golem15.mfa`), `mfa.Plugin` (ID, Requires `golem15.user`, Register, Boot, ConfigFS, LangFS, Routes, Buckets, Migrations, Models, MailTemplatesFS, MailTemplates, AdminFS, Settings, Permissions), `mfa.BucketTwoFactorVerify` (`2fa-verify`), `mfa.SettingsCode` (`mfa`), `mfa.PermissionAccessSettings` (`golem15.mfa.access_settings`).
+- classes: `Config`, `LoadConfig(app *backpack.App) (Config, error)`, `Effective` and `LoadEffective(ctx context.Context, app *backpack.App, db *gorm.DB) (Effective, error)`; `Gate` (implements `userclasses.LoginGate`); `NewChallengeToken() (string, error)`, `HashToken(raw string) string` (delegates to `userclasses.HashAPIToken`), `CreateChallenge`, `FindChallenge`, `BeginAttempt`, `CompleteChallenge`, `FailureCapReached`; `NewTOTPSecret`, `TOTPURL`, `VerifyTOTP(secret, code string, at time.Time, lastCounter int64) (int64, bool, error)`; `QRSVG(text string, size int) (string, error)`; `RecoveryCodeAlphabet`, `GenerateRecoveryCodes`, `NormalizeRecoveryCode`, `ConsumeRecoveryCode`, `RemainingRecoveryCodes`; `SendEmailCode`, `MaskEmail`, `VerifyEmailCode`; `IssueTrustedDevice`, `CheckTrustedDevice`, `RevokeTrustedDevices`, `ParseDeviceName`; `EnabledMethods`, `EnableMethod`, `DisableMethod`, `Status`; `CompleteLogin`; `Admin` (implements `userclasses.TwoFactorAdmin`); events `ChallengedEvent`, `FailedEvent`, `RecoveryUsedEvent`, `VerifiedEvent`, `EnabledEvent`, `DisabledEvent`, `RecoveryRegeneratedEvent` with constants `ChallengedEventName` (`golem15.user.2fa.challenged`), `FailedEventName`, `RecoveryUsedEventName`, `VerifiedEventName`, `EnabledEventName`, `DisabledEventName`, `RecoveryRegeneratedEventName`; errors `ErrInvalidChallenge`.
+- controllers: `Verify`, `Recovery`, `EmailSend`, `Status`, `TOTPSetup`, `TOTPConfirm`, `TOTPDisable`, `EmailEnable`, `EmailDisable`, `RecoveryCodesCount`, `RecoveryCodesRegenerate`, `TrustedDeviceRevoke`, `TrustedDevicesRevokeAll`.
+- models: `TwoFactorMethod`, `TwoFactorChallenge`, `TwoFactorRecoveryCode`, `TrustedDevice`, `Settings`, `StringList`, `LoadSettings(ctx context.Context, db *gorm.DB) (*Settings, error)`, constants `MethodTOTP`, `MethodEmail`, `MethodWebAuthn`, `MethodRecovery`, `ModeDisabled`, `ModeOptional`, `ModeEnforced`, `LangPrefix`.
+- migrations `202610220001_create_golem15_user_two_factor_methods`, `202610220002_create_golem15_user_two_factor_challenges`, `202610220003_create_golem15_user_two_factor_recovery_codes`, `202610220004_create_golem15_user_trusted_devices`, `202610220005_create_golem15_mfa_settings`.
+- Routes: challenge group `/_user/api/v1/2fa` with `throttle:2fa-verify`: `POST verify`, `POST recovery`, `POST email/send`; management group `/_user/api/v1/2fa` with `throttle:user-api` and `jwt.auth` per route: `GET status`, `POST totp/setup`, `POST totp/confirm`, `DELETE totp`, `POST email/enable`, `DELETE email`, `GET recovery-codes`, `POST recovery-codes/regenerate`, `DELETE trusted-devices/{id}`, `DELETE trusted-devices`.
+- Mail templates `golem15.mfa::mail.two_factor_code`, `golem15.mfa::mail.two_factor_code-en`; settings screen `mfa`; permission `golem15.mfa.access_settings`; lang keys `golem15.mfa::lang.settings.*`, `golem15.mfa::lang.permissions.*`, `golem15.mfa::lang.mail.*`.
+- Smoke tests `TestQRSVGSmoke`, `TestTracerSmoke`, `TestChallengeSmoke`, `TestTrustedSettingsSmoke`; harness `freshDB`, `bootMFA`.
+
+
+Build sm-mfa-plugin without WebAuthn: a TOTP tracer from setup through challenged login to a verified token, then recovery and email codes with the D-18 hardening, then trusted devices, the settings screen, revocation on password change and the admin reset service.
+
+Purpose: D-01, D-04 (TOTP and QR), D-05, D-06, D-07, D-09 (setting), D-10, D-12, D-13, D-15 (service), D-16 (five tables), D-18 (A6, A8, A9), D-19; SC-1 (TOTP, email, recovery, trusted devices), SC-2 (the challenge step behind 2fa-verify), SC-4 (models and migrations).
+Output: local commits in the new repository, each green.
+
+
+
+@~/.claude/msd-core/workflows/execute-plan.md
+@~/.claude/msd-core/templates/summary.md
+
+
+
+@.planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-CONTEXT.md
+@.planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-RESEARCH.md
+@.planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-PATTERNS.md
+@.planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-01-SUMMARY.md
+@.planning/notes/2026-10-21-mfa-otp-webauthn.md
+
+Repos and concurrency: this plan creates and writes ../sm-mfa-plugin (a sibling of summercms.go, like ../sm-sociallogin-plugin). It reads ../fonoteka.go/plugins/golem15/user (the sm-user-plugin checkout go.work replaces) and summercms.go and changes neither; a defect in sm-user-plugin that blocks a task is fixed there in its own commit with its README change and recorded 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 through `sudo -u golem` if the socket is denied, and record NOT RUN when containers are unreachable (Phase 19.8 saw that on this host).
+
+PHP source of truth (read-only, /media/nvme/dev/jz/wavepath.org/plugins/golem15/user): controllers/TwoFactorApiController.php (validation :37-218, management :229-568, withTrustedDevice :578-588), classes/twofactor/TwoFactorService.php (isEnabledForUser :33-47, getEnabledMethods :54-69, isMethodAvailable :82-90, isEnforcedForUser :95-109, createChallenge and verifyChallenge :116-205, confirmTotp :228-245, enableEmailMethod :250-262, disableMethod :267-294, removeWebAuthnCredential :322-356, trusted devices :455-530, getStatus :549-594, ensureRecoveryCodes :599-608), classes/twofactor/TotpMethod.php (:34-72), classes/twofactor/EmailMethod.php (:52-98), models/TwoFactorChallenge.php (:105-133), models/TwoFactorRecoveryCode.php (:49-112), models/TrustedDevice.php (:36-124), models/Settings.php (:40-63), models/settings/fields.yaml (:90-160), views/mail/two_factor_code.htm, updates/v3.0.0 and v3.1.0 migrations, routes.php (:8-18, :303-337, :372-453). RESEARCH Q4 quotes every body; the PHP files break ties.
+
+
+- sm-user-plugin after plan 01 (19.6-01-SUMMARY.md has the final names): `userclasses.LoginGate` (`CheckLogin(ctx, a LoginAttempt) (*LoginGateResult, error)`), `userclasses.LoginGates` (published in the user plugin's Register; add the gate in Boot), `userclasses.LoginAttempt{User, Source, Fields, Request}`, `userclasses.LoginGateResult{Status, Body}`, `userclasses.LoginSourcePassword`, `LoginSourceSocial`, `LoginSourceTwoFactor`, `userclasses.PasswordChangedEvent{User, Tx, Source}`, `userclasses.TwoFactorAdmin`, `userclasses.HashAPIToken`, `userclasses.MailTemplate`, `userclasses.ResolveMailLocale`, `userclasses.UserGroupCodes`, `usercontrollers.FinishLogin`, `IssueToken`, `APIArray`, `RemoteIP`, `FireLoginEvent`, `usermodels.User`, `usermodels.UserGroup` (group codes for the enforce-groups allow-list).
+- sm-sociallogin-plugin (template, read-only): plugin.go (assertions, embeds, Register/Boot without DB, typed Publish), routes.go, config.go and classes/config.go (LoadConfig), admin_settings.go, admin_permissions.go, models/settings.go (singleton row 1, LoadSettings, BeforeValidate with cabana.ValidationError), models/registry.go, updates/registry.go (`run` helper), updates/202610200001_create_golem15_sociallogin_settings.go, controllers/helpers.go (writeJSON with Cache-Control no-cache, private), testmain_test.go (flag.Parse, testing.Short, postgres:16-alpine, freshDB), harness_test.go (bootSocialWith: freshDB, lagoon.Use, httptest.NewUnstartedServer, temp config dir with app.yaml, party.Activate; blank import of the user plugin), go.mod, go.work, README.md.
+- Other analogs: ../sm-oauthserver-plugin/buckets.go (:29-111, per-IP bucket with surf.ClientIP and surf.TrustedProxies), ../sm-paymentgateway-plugin/mail.go (MailTemplatesFS and MailTemplates), ../sm-chatbots-plugin/boot.go (:82-98 typed listener using ev.Tx), ../fonoteka.go/plugins/golem15/user/controllers/api_controller.go (writeValidation :946, sendTemplateVars and mailVars, bcrypt cost read :102-105), ../fonoteka.go/plugins/golem15/user/controllers/api_tokens.go (apiTokenDeps), ../fonoteka.go/plugins/golem15/user/views/mail/restore-en.htm (template front matter).
+- Framework: `bouncer.HashPassword(cost, plain)`, `bouncer.CheckPassword(hash, plain)`, `bouncer.User(ctx)`, `lagoon.Encrypted`, `lagoon.Validate`, `wire.Time`, `wire.Slice`, `wire.WriteJSON`, `surf.Bucket{Name, Max, Decay, Key}`, `surf.ClientIP`, `surf.TrustedProxies`, `postcard.Mailer`, `cabana.ValidationError`, `pact.SettingsItem`, `app.Events.Fire`, `app.Events.Listen`.
+- Libraries (after plan 01 Task 5 approval only): `github.com/pquerna/otp` (`totp.Generate`, `totp.GenerateCodeCustom`, `hotp.ValidateCustom`, `otp.Key.URL()`), `github.com/boombuler/barcode/qr` (`qr.Encode(text, qr.M, qr.Auto)`).
+- RESEARCH Code Examples 1-4, Patterns 2, 5, Pitfalls 1, 4, 5, 9, 10, 11, 12, 17, 18, 19, 20.
+
+
+
+
+
+
+ Task 1: Tracer: a user turns TOTP on through setup (QR as SVG) and confirm, the next password login answers the challenge instead of a token, and the current TOTP code completes it with {token, user} exactly once behind the 2fa-verify bucket
+ 19.6-01-SUMMARY.md records the Task 5 reply "approved"; `test ! -e ../sm-mfa-plugin` exits 0 or the directory holds only this plan's earlier work.
+ D-01: the module path, plugin ID golem15.mfa, the /_user/api/v1/2fa routes and the golem15.mfa config keys become what applications' summer.yaml, config and frontends reference once plan 03 releases the plugin.
+ ../sm-mfa-plugin/.gitignore, ../sm-mfa-plugin/go.mod, ../sm-mfa-plugin/go.sum, ../sm-mfa-plugin/go.work, ../sm-mfa-plugin/plugin.go, ../sm-mfa-plugin/routes.go, ../sm-mfa-plugin/buckets.go, ../sm-mfa-plugin/config/config.yaml, ../sm-mfa-plugin/lang/en/lang.yaml, ../sm-mfa-plugin/lang/pl/lang.yaml, ../sm-mfa-plugin/classes/config.go, ../sm-mfa-plugin/classes/gate.go, ../sm-mfa-plugin/classes/challenge.go, ../sm-mfa-plugin/classes/login.go, ../sm-mfa-plugin/classes/totp.go, ../sm-mfa-plugin/classes/qr.go, ../sm-mfa-plugin/classes/methods.go, ../sm-mfa-plugin/classes/events.go, ../sm-mfa-plugin/controllers/helpers.go, ../sm-mfa-plugin/controllers/challenge.go, ../sm-mfa-plugin/controllers/management.go, ../sm-mfa-plugin/models/registry.go, ../sm-mfa-plugin/models/two_factor_method.go, ../sm-mfa-plugin/models/two_factor_challenge.go, ../sm-mfa-plugin/updates/registry.go, ../sm-mfa-plugin/updates/202610220001_create_golem15_user_two_factor_methods.go, ../sm-mfa-plugin/updates/202610220002_create_golem15_user_two_factor_challenges.go, ../sm-mfa-plugin/testmain_test.go, ../sm-mfa-plugin/harness_test.go, ../sm-mfa-plugin/tracer_smoke_test.go, ../sm-mfa-plugin/README.md
+ 19.6-01-SUMMARY.md (Task 5 reply, final user-plugin identifiers), .planning/notes/2026-10-21-mfa-otp-webauthn.md, ../sm-sociallogin-plugin/plugin.go, ../sm-sociallogin-plugin/routes.go, ../sm-sociallogin-plugin/config.go, ../sm-sociallogin-plugin/classes/config.go, ../sm-sociallogin-plugin/config/config.yaml, ../sm-sociallogin-plugin/go.mod, ../sm-sociallogin-plugin/go.work, ../sm-sociallogin-plugin/testmain_test.go, ../sm-sociallogin-plugin/harness_test.go, ../sm-sociallogin-plugin/controllers/helpers.go, ../sm-sociallogin-plugin/updates/registry.go, ../sm-sociallogin-plugin/models/registry.go, ../sm-sociallogin-plugin/README.md, ../sm-oauthserver-plugin/buckets.go, ../fonoteka.go/plugins/golem15/user/classes/login_gate.go, ../fonoteka.go/plugins/golem15/user/controllers/registration.go, ../fonoteka.go/plugins/golem15/user/updates/202610040001_create_api_tokens.go, ../fonoteka.go/plugins/golem15/user/models/api_token.go, modules/lagoon/encrypted.go, 19.6-RESEARCH.md (Q1, Q2, Q4 login challenge, challenge endpoints, status, TOTP, tables; Patterns 1-2; Code Examples 2-4; Pitfalls 4, 5, 17, 20), /media/nvme/dev/jz/wavepath.org/plugins/golem15/user/classes/twofactor/TotpMethod.php, /media/nvme/dev/jz/wavepath.org/plugins/golem15/user/controllers/TwoFactorApiController.php (:37-66, :229-300)
+ Repository (D-01): `git init -b master ../sm-mfa-plugin`, no remote. go.mod: `module git.golem15.com/golem15/sm-mfa-plugin`, `go 1.27.0`, the framework and sm-user-plugin requires as this plan's Decisions state, then `go get github.com/pquerna/otp@v1.5.0 github.com/boombuler/barcode@v1.1.0` and `go mod tidy` (go-webauthn is installed in plan 03). go.work: `use .`, replace the framework with ../summercms.go and sm-user-plugin with ../fonoteka.go/plugins/golem15/user (sociallogin's go.work). .gitignore for test binaries and coverage files.
+
+plugin.go (Pitfall 20): `PluginID = "golem15.mfa"`, interface assertions (party.Plugin, pact.HasConfig, pact.HasLang, pact.HasRoutes, pact.HasMigrations, pact.HasModels, surf.BucketProvider), `//go:embed config` and `lang`, Requires `golem15.user`, Register stores the app, Boot runs LoadConfig (config only) and adds `&classes.Gate{App: app}` to the `*userclasses.LoginGates` it looks up (an error when the registry is missing, since golem15.user always publishes it), `init() { party.Register(&Plugin{}) }`. buckets.go: `BucketTwoFactorVerify = "2fa-verify"`, Max 10, Decay one minute, keyed `"2fa-verify-ip:" + surf.ClientIP(r, trusted)` with trusted proxies from the app config (oauthserver's per-IP bucket). config/config.yaml with the oauthserver header style (each key's SUMMER_GOLEM15__MFA__... env form) and the defaults listed in Decisions; classes/config.go `Config`, `LoadConfig`, and `Effective`/`LoadEffective` returning the config values for now (Task 3 overlays the settings row without changing the signature).
+
+Migrations (raw SQL, D-16, RESEARCH Q4 tables): `golem15_user_two_factor_methods` (id BIGSERIAL PK, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, method VARCHAR(20) NOT NULL, is_enabled BOOLEAN NOT NULL DEFAULT TRUE, secret TEXT NULL, metadata JSONB NULL, last_used_at TIMESTAMPTZ NULL, created_at, updated_at, UNIQUE (user_id, method), index (user_id, is_enabled)); `golem15_user_two_factor_challenges` (id BIGSERIAL PK, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, token VARCHAR(64) NOT NULL UNIQUE (SHA-256 hex, D-06), method VARCHAR(20) NULL, code VARCHAR(255) NULL, attempts SMALLINT NOT NULL DEFAULT 0, max_attempts SMALLINT NOT NULL DEFAULT 5, email_sends SMALLINT NOT NULL DEFAULT 0, ip_address VARCHAR(45) NULL, user_agent TEXT NULL, expires_at TIMESTAMPTZ NOT NULL, completed_at TIMESTAMPTZ NULL, created_at, updated_at, index (user_id, expires_at)); rollbacks drop them. Models with explicit column tags: `TwoFactorMethod` (Secret `lagoon.Encrypted` with `json:"-"`, Metadata as a JSON map), `TwoFactorChallenge` (TokenHash `gorm:"column:token"` `json:"-"`, Code `json:"-"`); method and mode constants in models.
+
+classes: `NewChallengeToken` (48 crypto/rand bytes, base64url, 64 characters) and `HashToken` (userclasses.HashAPIToken). `CreateChallenge(ctx, db, user, ip, userAgent, ttl)` deletes the user's rows whose expires_at is older than the failure window, inserts a row with the hash and returns the plaintext token and the row. `FindChallenge(ctx, db, token)` returns the row only when uncompleted, unexpired and attempts below max. `BeginAttempt(ctx, db, token)` is RESEARCH Code Example 2's guarded UPDATE (`SET attempts = attempts + 1, updated_at = NOW() WHERE token = ? AND completed_at IS NULL AND expires_at > NOW() AND attempts < max_attempts`) returning the row or `ErrInvalidChallenge` when RowsAffected is 0; `CompleteChallenge(ctx, tx, id)` is the guarded `SET completed_at = NOW() WHERE id = ? AND completed_at IS NULL` that fails unless exactly one row changed. totp.go: `NewTOTPSecret(issuer, account)` through `totp.Generate` (pquerna defaults: SHA1, 6 digits, 30 s, 20-byte secret), `TOTPURL`, and `VerifyTOTP(secret, code, at, lastCounter)` per RESEARCH Code Example 3 (counters at-1, at, at+1 with `hotp.ValidateCustom`; a match at or below lastCounter is a replay and fails; returns the matched counter). qr.go `QRSVG(text, size)` per RESEARCH Code Example 4 (qr.Encode level M, 4-module quiet zone, one path of unit squares, width and height = size, viewBox over the module grid, crispEdges). methods.go: `EnabledMethods(ctx, db, userID)` (PHP getEnabledMethods), `EnableMethod`, minimal `Status` (enabled, enforced, methods; Task 3 completes the PHP shape). gate.go `Gate.CheckLogin`: Source password (social waits for Task 3's toggle; passkey, two_factor and device never challenge); mode disabled → nil; no enabled method → nil; else CreateChallenge with the configured TTL, fire ChallengedEvent, return Status 200 with Body `{"two_factor_required": true, "challenge_token": token, "available_methods": wire.Slice(methods), "expires_at": wire.Time(expires)}`. login.go `CompleteLogin(ctx, app, db, r, user)`: `usercontrollers.FinishLogin` (restores a deactivated account only now, D-18 A10), `IssueToken`, `APIArray`, `FireLoginEvent` with `userclasses.LoginSourceTwoFactor` (logged on error), returning `{"token", "user"}`. events.go: the typed structs and PHP names for the events this plan fires (Artifacts list), each carrying User, Method and Tx where a transaction exists.
+
+controllers (handler factories over *backpack.App; helpers.go copies sociallogin's writeJSON and the user plugin's writeValidation): `TOTPSetup` (jwt.auth; 403 `{"error":"TOTP is not available."}` unless PHP isMethodAvailable holds: mode not disabled and totp in the available methods; answers `{secret, qr_code_svg, qr_code_url}` with issuer `golem15.mfa.issuer` or app.name and account the user's email); `TOTPConfirm` (fields `secret` required string and base32 with length 16 to 64, `code` required string size 6, PHP 422 shapes; VerifyTOTP at now with lastCounter -1 else 422 `{"error":"Invalid verification code."}`; upsert the totp row enabled with the encrypted secret and metadata `{"last_counter": n}`; fire EnabledEvent; answer `{"message":"TOTP enabled successfully.","status":...}`); `Status` (minimal for now); `Verify` (unauthenticated; validation `challenge_token` required|string|size:64, `method` required|string|in:totp,email, `code` required|string|min:6|max:6 with PHP's 422 `{error: first message, errors}`; BeginAttempt else 401 `{"error":"Invalid verification code.","two_factor_failed":true}`; method totp: the user's enabled totp row, VerifyTOTP against its last_counter; failure fires FailedEvent and answers that 401; success runs in one transaction: CompleteChallenge, store the new last_counter and last_used_at, then CompleteLogin and VerifiedEvent after the login event; answer `{token, user}`; method email answers the same 401 until Task 2). routes.go: `r.Group("/_user/api/v1/2fa", surf.Use("throttle:2fa-verify"), ...)` with `g.Post("/verify", ...)`; `r.Group("/_user/api/v1/2fa", surf.Use("throttle:user-api"), ...)` with `g.Get("/status", ..., "jwt.auth")`, `g.Post("/totp/setup", ..., "jwt.auth")`, `g.Post("/totp/confirm", ..., "jwt.auth")`.
+
+Tests: testmain_test.go and harness_test.go copied from sm-sociallogin-plugin and renamed (database `golem15_mfa`, `bootMFA(t, env map[string]string) (*backpack.App, *httptest.Server)` activating golem15.user and golem15.mfa with the memory cooler and a capturing mailer; helpers to insert an activated user, log in and call with a bearer). tracer_smoke_test.go: `TestQRSVGSmoke` (no database, -short: QRSVG of an otpauth URL has width and height 200 and a viewBox; parsing the path's unit squares back gives exactly the dark modules of `qr.Encode` for the same text; an empty text returns an error). `TestTracerSmoke` (Postgres): a user calls totp/setup and gets a 32-character base32 secret, an `
+
+
+ Task 2: Recovery codes and email codes: the first set is shown once, a code works once, regenerate replaces the set, email codes go only to users who enabled them, and a per-user failure cap stops brute force across challenges
+ ../sm-mfa-plugin/models/two_factor_recovery_code.go, ../sm-mfa-plugin/models/registry.go, ../sm-mfa-plugin/updates/202610220003_create_golem15_user_two_factor_recovery_codes.go, ../sm-mfa-plugin/updates/registry.go, ../sm-mfa-plugin/classes/recovery.go, ../sm-mfa-plugin/classes/email.go, ../sm-mfa-plugin/classes/challenge.go, ../sm-mfa-plugin/classes/gate.go, ../sm-mfa-plugin/classes/methods.go, ../sm-mfa-plugin/classes/events.go, ../sm-mfa-plugin/controllers/challenge.go, ../sm-mfa-plugin/controllers/management.go, ../sm-mfa-plugin/routes.go, ../sm-mfa-plugin/mail.go, ../sm-mfa-plugin/plugin.go, ../sm-mfa-plugin/views/mail/two_factor_code.htm, ../sm-mfa-plugin/views/mail/two_factor_code-en.htm, ../sm-mfa-plugin/lang/en/lang.yaml, ../sm-mfa-plugin/lang/pl/lang.yaml, ../sm-mfa-plugin/challenge_smoke_test.go, ../sm-mfa-plugin/README.md
+ ../sm-mfa-plugin classes and controllers from Task 1, ../sm-paymentgateway-plugin/mail.go, ../fonoteka.go/plugins/golem15/user/plugin.go (MailTemplates), ../fonoteka.go/plugins/golem15/user/views/mail/restore-en.htm, ../fonoteka.go/plugins/golem15/user/controllers/api_controller.go (sendTemplateVars, mailVars, bcrypt cost read), ../fonoteka.go/plugins/golem15/user/classes/mail.go, modules/postcard/templates.go (:115-199 ownership and locale rules), /media/nvme/dev/jz/wavepath.org/plugins/golem15/user/models/TwoFactorRecoveryCode.php (:49-112), /media/nvme/dev/jz/wavepath.org/plugins/golem15/user/classes/twofactor/EmailMethod.php, /media/nvme/dev/jz/wavepath.org/plugins/golem15/user/views/mail/two_factor_code.htm, /media/nvme/dev/jz/wavepath.org/plugins/golem15/user/classes/twofactor/TwoFactorService.php (:116-205, :228-294, :599-608), /media/nvme/dev/jz/wavepath.org/plugins/golem15/user/controllers/TwoFactorApiController.php (:74-138, :300-560), 19.6-RESEARCH.md (Q3, Q4 recovery codes, email method, mail template, method enable/disable; Pitfalls 1, 9, 10, 11, 12)
+ Per D-05, D-06, D-07 (last method), D-18 A6/A8/A9, D-19.
+
+Recovery codes: migration `golem15_user_two_factor_recovery_codes` (id BIGSERIAL PK, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, code VARCHAR(255) NOT NULL, used_at TIMESTAMPTZ NULL, created_at TIMESTAMPTZ NULL, no updated_at, index (user_id, used_at)) and model. classes/recovery.go: `RecoveryCodeAlphabet = "23456789ABCDEFGHJKMNPQRSTUVWXYZ"`; `GenerateRecoveryCodes(ctx, tx, userID, cost)` deletes the user's codes and inserts 10 new ones (each two 5-character parts drawn with crypto/rand without modulo bias, joined by a hyphen, stored as `bouncer.HashPassword(cost, code)` with cost from `golem15.user.password.bcrypt_cost`, default 10) and returns the plaintexts; `NormalizeRecoveryCode` (PHP: uppercase, spaces removed, hyphen kept; empty or longer than 32 characters is no match); `ConsumeRecoveryCode(ctx, tx, userID, input)` compares against the unused codes with `bouncer.CheckPassword`, stops at the first match and marks it with the guarded `UPDATE ... SET used_at = NOW() WHERE id = ? AND used_at IS NULL` (a lost race is no match); `RemainingRecoveryCodes`. Enabling (TOTPConfirm here, EmailEnable below) runs PHP's ensureRecoveryCodes inside its transaction: when the user has no unused code, generate a set and add `recovery_codes` (the 10 plaintexts) to the response (D-19); otherwise the body is PHP's.
+
+Challenge endpoints: `Recovery` (validation `challenge_token` required|string|size:64, `code` required|string; BeginAttempt; ConsumeRecoveryCode; success → CompleteChallenge, RecoveryUsedEvent with the remaining count, CompleteLogin, VerifiedEvent with method recovery; failure → FailedEvent and 401 `{"error":"Invalid recovery code.","two_factor_failed":true}`). Email (classes/email.go): `MaskEmail` (PHP: first character, `*` for each of the remaining but last, last character, `@domain`; a local part of 2 or fewer characters gives first character plus `***`); `SendEmailCode(ctx, app, db, challenge, user)`: the user's email method must be enabled (A8); a code `fmt.Sprintf("%06d", n)` with n uniform in [0, 999999] from crypto/rand; one guarded `UPDATE ... SET code = ?, method = 'email', email_sends = email_sends + 1, updated_at = NOW() WHERE id = ? AND completed_at IS NULL AND expires_at > NOW() AND email_sends < 3` with the bcrypt hash (RowsAffected 0 → ErrInvalidChallenge); mail `userclasses.MailTemplate("golem15.mfa::mail.two_factor_code", userclasses.ResolveMailLocale(ctx, user))` through `postcard.Mailer` with vars name, code and expiry_minutes = min(two_factor_email_code_ttl, remaining challenge minutes rounded up, at least 1) in both cases (A9); never log the code. `EmailSend` controller: validation (PHP: 422 `{error}` without `errors`), FindChallenge else 401 `{"error":"Invalid or expired challenge."}`, user missing → 401 `{"error":"Invalid challenge."}`, email method not enabled, send cap reached or cap A6 reached → 401 `{"error":"Invalid or expired challenge."}`; success `{"message":"Verification code sent.","email":}`. `Verify` method email: after BeginAttempt, the email method must be enabled and the row's method email with a code (else 401 `{"error":"Invalid or expired challenge."}`, A8); a bcrypt match completes as TOTP does, a mismatch fires FailedEvent and answers PHP's invalid-code 401. mail.go embeds views/mail and lists both template names; plugin.go asserts pact.HasMailTemplates. Templates: two_factor_code-en.htm with PHP's subject "Your verification code" and body (Hi {{ .name }}, the code in bold, "This code expires in {{ .expiry_minutes }} minutes.", the secure-your-account sentence); two_factor_code.htm the same in Polish (the unsuffixed template is the Polish one, as in the user plugin); no layout header.
+
+Failure cap (D-18 A6, Pitfall 1): `FailureCapReached(ctx, db, userID, now)` with the formula in this plan's Decisions; the gate returns Status 401 with `{"error":"Invalid or expired challenge."}` and creates no challenge when reached; Verify, Recovery and EmailSend check it after resolving the challenge's user and answer their generic 401 (the attempt still counts).
+
+Management: `EmailEnable` (403 `{"error":"Email 2FA is not available."}` unless available; upsert enabled; ensureRecoveryCodes with D-19; EnabledEvent; `{"message":"Email 2FA enabled.","status":...}`), `EmailDisable` and `TOTPDisable` through `DisableMethod(ctx, tx, user, method)`: set is_enabled false and fire DisabledEvent; when no enabled method remains, delete the user's recovery codes (PHP) and trusted devices (D-07; the trusted-device table arrives in Task 3, so Task 3 extends this call) in the same transaction; answers `{"message":"TOTP disabled.","status":...}` and `{"message":"Email 2FA disabled.","status":...}`. `RecoveryCodesCount` (GET recovery-codes, `password` query checked with bouncer.CheckPassword against the user's hash else 403 `{"error":"Password confirmation required."}`; `{"recovery_codes_remaining":N,"message":"Recovery codes cannot be viewed after creation. Regenerate to get new codes."}`), `RecoveryCodesRegenerate` (POST with `password`; same 403; GenerateRecoveryCodes in one transaction, RecoveryRegeneratedEvent; `{"recovery_codes":[...],"message":"Save these codes securely. They will not be shown again."}`). Routes: challenge group `g.Post("/recovery", ...)`, `g.Post("/email/send", ...)`; management group `g.Delete("/totp", ...)`, `g.Post("/email/enable", ...)`, `g.Delete("/email", ...)`, `g.Get("/recovery-codes", ...)`, `g.Post("/recovery-codes/regenerate", ...)`, each with jwt.auth.
+
+README (same commit): Features (recovery codes, email codes, failure cap, first set shown once), Routes, Configuration (failure_cap keys, email TTL), Security notes (bcrypt cost per wrong recovery code, email only when enabled). Smoke test challenge_smoke_test.go `TestChallengeSmoke` (Postgres) with subtests: `recovery_first_set` (totp/confirm returns 10 codes matching `^[2-9A-HJKMNP-Z]{5}-[2-9A-HJKMNP-Z]{5}$`; a second enable of email answers without recovery_codes; the stored codes are bcrypt hashes); `recovery_once` (a code completes a challenge; the same code on a new challenge answers the PHP 401; GET recovery-codes with the password reports 9; without the password 403); `regenerate` (old codes stop working, new ones work); `email_only_when_enabled` (a TOTP-only user: email/send answers 401 Invalid or expired challenge and no mail is sent; after email/enable, send answers the masked email, the captured mail uses the -en template for an English user with a 6-digit code and expiry_minutes at most 5, and verify method email with that code returns a token); `email_send_cap` (the fourth send on one challenge answers 401); `five_attempts` (five wrong codes on one challenge, then the correct code answers 401); `parallel_verify` (20 goroutines verify the same challenge with the correct code: exactly one 200); `failure_cap` (11 wrong verifications across three challenges, then login answers 401 {"error":"Invalid or expired challenge."} and creates no row; moving the rows' updated_at back past the window lets login challenge again); `disable_last_method` (DELETE totp on a TOTP-only user deletes the recovery codes). Commit: `feat: recovery codes, email codes and the per-user failure cap`.
+
+ go -C ../sm-mfa-plugin vet ./... && go -C ../sm-mfa-plugin test -count=1 -short ./...
+ Non-zero exit: vet or a short test is red (for example the mail templates fail postcard's ownership check at registration).
+ go -C ../sm-mfa-plugin test -count=1 -run '^(TestTracerSmoke|TestChallengeSmoke)$' -v . 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 2
+ The count of top-level "--- PASS: Test" lines is not 2: a challenge subtest failed, the tracer regressed, or a test skipped because Docker was unreachable (rerun through sudo -u golem, else record NOT RUN).
+
+
+ - `grep -c '23456789ABCDEFGHJKMNPQRSTUVWXYZ' ../sm-mfa-plugin/classes/recovery.go` prints at least 1 and `grep -c 'used_at IS NULL' ../sm-mfa-plugin/classes/recovery.go` prints at least 1.
+ - `grep -c 'email_sends < 3' ../sm-mfa-plugin/classes/email.go` prints 1.
+ - `grep -c 'golem15.mfa::mail.two_factor_code' ../sm-mfa-plugin/mail.go` prints 2 and `grep -c 'Your verification code' ../sm-mfa-plugin/views/mail/two_factor_code-en.htm` prints 1.
+ - `grep -c 'func FailureCapReached' ../sm-mfa-plugin/classes/challenge.go` prints 1 and `grep -c 'FailureCapReached(' ../sm-mfa-plugin/classes/gate.go` prints at least 1.
+ - `grep -c '"recovery_codes"' ../sm-mfa-plugin/controllers/management.go` prints at least 2 (regenerate and the D-19 first set).
+ - `grep -c '"Invalid recovery code."' ../sm-mfa-plugin/controllers/challenge.go` prints 1 and `grep -c '"Password confirmation required."' ../sm-mfa-plugin/controllers/management.go` prints at least 1.
+
+ A user can sign in with a recovery code or an emailed code when they set those up, every code works once, and guessing is stopped per user across challenges and IP addresses.
+
+
+
+ Task 3: Trusted devices skip the challenge until a password change or the last method goes, an administrator configures 2FA in a settings screen without restarting, and the Users screen's Reset 2FA clears a user's second factors
+ ../sm-mfa-plugin/models/trusted_device.go, ../sm-mfa-plugin/models/settings.go, ../sm-mfa-plugin/models/string_list.go, ../sm-mfa-plugin/models/settings/fields.yaml, ../sm-mfa-plugin/models/registry.go, ../sm-mfa-plugin/updates/202610220004_create_golem15_user_trusted_devices.go, ../sm-mfa-plugin/updates/202610220005_create_golem15_mfa_settings.go, ../sm-mfa-plugin/updates/registry.go, ../sm-mfa-plugin/classes/trusted.go, ../sm-mfa-plugin/classes/config.go, ../sm-mfa-plugin/classes/gate.go, ../sm-mfa-plugin/classes/methods.go, ../sm-mfa-plugin/classes/admin.go, ../sm-mfa-plugin/controllers/challenge.go, ../sm-mfa-plugin/controllers/management.go, ../sm-mfa-plugin/routes.go, ../sm-mfa-plugin/admin_settings.go, ../sm-mfa-plugin/admin_permissions.go, ../sm-mfa-plugin/plugin.go, ../sm-mfa-plugin/lang/en/lang.yaml, ../sm-mfa-plugin/lang/pl/lang.yaml, ../sm-mfa-plugin/trusted_settings_smoke_test.go, ../sm-mfa-plugin/README.md
+ ../sm-mfa-plugin classes, controllers and models from Tasks 1-2, ../sm-sociallogin-plugin/admin_settings.go, ../sm-sociallogin-plugin/admin_permissions.go, ../sm-sociallogin-plugin/models/settings.go, ../sm-sociallogin-plugin/models/settings/fields.yaml, ../sm-sociallogin-plugin/updates/202610200001_create_golem15_sociallogin_settings.go, ../sm-sociallogin-plugin/settings_smoke_test.go, modules/cabana/settings.go (:98-103), modules/cabana/form_schema.go (:23-30), modules/cabana/field_taglist.go (:124-139), ../sm-chatbots-plugin/boot.go (:82-98), ../fonoteka.go/plugins/golem15/user/classes/two_factor_admin.go, ../fonoteka.go/plugins/golem15/user/classes/events.go, ../fonoteka.go/plugins/golem15/user/classes/user_groups.go, ../fonoteka.go/plugins/golem15/user/reset_two_factor_smoke_test.go, /media/nvme/dev/jz/wavepath.org/plugins/golem15/user/models/TrustedDevice.php, /media/nvme/dev/jz/wavepath.org/plugins/golem15/user/classes/twofactor/TwoFactorService.php (:33-47, :95-109, :455-530, :549-594), /media/nvme/dev/jz/wavepath.org/plugins/golem15/user/models/Settings.php, /media/nvme/dev/jz/wavepath.org/plugins/golem15/user/models/settings/fields.yaml (:90-160), 19.6-RESEARCH.md (Q4 trusted devices, status shape, settings; Q8; Q9; Pattern 5; Pitfalls 2, 19; Assumptions A2, A7)
+ Per D-07, D-09 (setting), D-10, D-12, D-13, D-15, D-16.
+
+Trusted devices: migration `golem15_user_trusted_devices` (id BIGSERIAL PK, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, token VARCHAR(64) NOT NULL UNIQUE (SHA-256 hex), device_name VARCHAR(255) NULL, ip_address VARCHAR(45) NULL, trusted_until TIMESTAMPTZ NOT NULL, last_used_at TIMESTAMPTZ NULL, created_at, updated_at, index (user_id)) and model. classes/trusted.go: `ParseDeviceName(userAgent)` ports PHP's "{browser} on {os}" parse (TrustedDevice.php); `IssueTrustedDevice(ctx, db, user, ip, userAgent, ttlDays)` stores the hash and returns the 64-character token; `CheckTrustedDevice(ctx, db, userID, token, ip)` requires a row of that user with trusted_until in the future, then updates last_used_at and ip_address; `RevokeTrustedDevices(ctx, tx, userID)`. Verify, Recovery (and plan 03's WebAuthn verify) add `trusted_device_token` to the success body only when the request's `trust_device` is truthy and trusted_device_enabled is on (PHP withTrustedDevice). The gate allows (returns nil) when trusted devices are enabled and the attempt's Fields carry a valid `trusted_device_token` for the user (Pitfall 2); social sign-ins carry no fields, so trusted devices never skip a social challenge (A7). DisableMethod's last-method branch now also deletes trusted devices (D-07). plugin.go Boot registers `app.Events.Listen[*userclasses.PasswordChangedEvent](PluginID, ...)` deleting the user's trusted devices on ev.Tx (falling back to the published DB when Tx is nil), returning the delete's error so the password write rolls back on failure. Management routes `g.Delete("/trusted-devices/{id}", ...)` followed directly by `g.Where("id", "[0-9]+")` (Pitfall 17) answering `{"message":"Device revoked.","status":...}` or 404 `{"error":"Device not found."}` scoped by user_id, and `g.Delete("/trusted-devices", ...)` answering `{"message":"All trusted devices revoked.","status":...}`.
+
+Settings (D-12, RESEARCH Q8): migration `golem15_mfa_settings` (id INTEGER PRIMARY KEY, two_factor_mode VARCHAR(16) NULL, two_factor_available_methods JSONB NULL, two_factor_enforce_groups JSONB NULL, two_factor_email_code_ttl INTEGER NULL, two_factor_challenge_ttl INTEGER NULL, two_factor_passwordless_login BOOLEAN NULL, trusted_device_enabled BOOLEAN NULL, trusted_device_ttl_days INTEGER NULL, two_factor_social_login BOOLEAN NULL, created_at, updated_at; inserts row 1) through the `run` helper. models/string_list.go `StringList` ([]string as JSON with Scan and Value). models/settings.go: singleton like sociallogin's (TableName, Fillable, `Rules()` with in:disabled,optional,enforced for the mode and integer ranges 1-60 for the TTLs and 1-365 for the trust days, `BeforeValidate` refusing unknown methods outside totp, email, webauthn and unknown group codes with `cabana.ValidationError`), `LoadSettings(ctx, db)`. models/settings/fields.yaml: tabs General (mode dropdown with three static options, available methods taglist mode array, enforce groups taglist mode array, social login switch with a comment that the frontend's OAuth callback must handle two_factor_required before it is turned on, D-09), Codes (email and challenge TTL numbers), Passkeys (passwordless switch with a comment that plan 03's routes use it), Trusted devices (switch and days). classes/config.go `LoadEffective` overlays each non-NULL settings value on the config per call (D-12: no restart), treating a missing table or DB as config only. admin_settings.go and admin_permissions.go as sociallogin's (Code `mfa`, Category Users, Order 540, permission `golem15.mfa.access_settings` for the developer role). Gate: reads LoadEffective per call; mode disabled exempts everyone (D-13; there is no other exemption); Source social challenges only when two_factor_social_login is on (D-09, default skip).
+
+Status (PHP getStatus, TwoFactorService.php:549-594): `{enabled, enforced, methods, webauthn_credentials: [], recovery_codes_remaining, available_methods (the effective global list), trusted_device_enabled, trusted_devices}`; trusted_devices only when enabled, unexpired, ordered last_used_at DESC, items `{id, device_name (default "Unknown Device"), ip_address, last_used_at, trusted_until, created_at}` with Carbon's JSON form `YYYY-MM-DDTHH:MM:SS.000000Z` in UTC (RESEARCH A2; one helper `carbonJSON`), webauthn_credentials stays an empty list until plan 03. Every management answer that embeds `status` uses this.
+
+Admin reset (D-15): classes/admin.go `Admin{App *backpack.App}` implementing `userclasses.TwoFactorAdmin`: HasTwoFactor is true when the user has an enabled method; Reset, on the given transaction, sets every method row disabled with secret and metadata cleared, deletes recovery codes and trusted devices and fires DisabledEvent with method `all` (plan 03 adds the WebAuthn credentials). Boot publishes it with `app.Publish[userclasses.TwoFactorAdmin](&classes.Admin{App: app})`.
+
+README (same commit): Features (trusted devices, settings screen, social toggle, admin reset, revocation rules), Configuration (every key with its default and env form), Admin section, API reference rows. Smoke test trusted_settings_smoke_test.go `TestTrustedSettingsSmoke` (Postgres) with subtests: `trusted_skip` (trusted devices enabled in config: verify with trust_device true returns trusted_device_token, the stored token column is its SHA-256 hex, the next login with it in the body answers a token directly; another user's token or an expired one is challenged); `trusted_disabled` (with the setting off, trust_device adds no token); `password_change_revokes` (change-password through the user plugin deletes the user's trusted devices; a listener failure rolls the change back); `settings_row_applies` (saving mode disabled in the row makes the next login answer a token without restarting; setting it back to optional challenges again); `social_toggle` (the gate called with Source social returns nil while the toggle is off and a challenge when on); `allow_lists` (BeforeValidate refuses methods ["sms"] and an unknown group code); `status_shape` (status keys exactly as PHP's, trusted device dates in the Carbon form); `admin_reset` (calling the published TwoFactorAdmin on a user with TOTP, codes and a trusted device leaves no enabled method, no codes, no devices, fires one disabled event, and the next login answers a token). Commit: `feat: trusted devices, the settings screen and the admin reset service`.
+
+ go -C ../sm-mfa-plugin vet ./... && go -C ../sm-mfa-plugin test -count=1 -short ./...
+ Non-zero exit: vet or a short test is red (for example cabana refuses the settings form at boot because of an unsupported field type).
+ go -C ../sm-mfa-plugin test -count=1 -run '^(TestTracerSmoke|TestChallengeSmoke|TestTrustedSettingsSmoke)$' -v . 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 3
+ The count of top-level "--- PASS: Test" lines is not 3: a trusted-device, settings or reset subtest failed, an earlier smoke regressed, or a test skipped because Docker was unreachable (rerun through sudo -u golem, else record NOT RUN).
+
+
+ - `grep -c 'Listen\[\*userclasses.PasswordChangedEvent\]' ../sm-mfa-plugin/plugin.go` prints 1 and `grep -c 'Publish\[userclasses.TwoFactorAdmin\]' ../sm-mfa-plugin/plugin.go` prints 1.
+ - `grep -cE 'type: (checkboxlist|radio)' ../sm-mfa-plugin/models/settings/fields.yaml` prints 0 and `grep -c 'mode: array' ../sm-mfa-plugin/models/settings/fields.yaml` prints at least 2.
+ - `grep -c 'two_factor_social_login' ../sm-mfa-plugin/updates/202610220005_create_golem15_mfa_settings.go` prints at least 1 and `grep -c 'two_factor_social_login' ../sm-mfa-plugin/config/config.yaml` prints at least 1.
+ - `grep -A1 '"/trusted-devices/{id}"' ../sm-mfa-plugin/routes.go | grep -c 'Where("id", "\[0-9\]+")'` prints 1.
+ - `grep -c '"trusted_device_token"' ../sm-mfa-plugin/controllers/challenge.go` prints at least 1.
+ - `grep -cE 'golem15.mfa.access_settings|two_factor_mode|trusted_device_enabled' ../sm-mfa-plugin/README.md` prints at least 3.
+ - `R=$(git -C ../sm-mfa-plugin remote) && test -z "$R"` exits 0 and `test -z "$(git -C ../sm-mfa-plugin status --porcelain)"` exits 0.
+
+ Users can trust a device for a month, administrators tune 2FA from the backend with immediate effect, and a password change or a reset by an administrator removes every way around the second factor.
+
+
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+|----------|-------------|
+| holder of a correct password to the challenge routes | unauthenticated clients present challenge tokens and guessed codes |
+| signed-in user to the management routes | a bearer JWT changes the user's own factors and reads recovery-code counts |
+| plugin to the database | tokens, secrets and codes at rest |
+| plugin to the mailer | one-time codes leave the system by email |
+| administrator to the settings screen and the reset action | global 2FA policy and per-user resets |
+
+## STRIDE Threat Register
+
+| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
+|-----------|----------|-----------|----------|-------------|-----------------|
+| T-19.6-13 | Spoofing | TOTP and email brute force across challenges and IPs (Pitfall 1) | high | mitigate | 2fa-verify bucket 10/min per IP plus the per-user failure cap counted in the database (10 per 15 min, D-18 A6); five attempts per challenge; TestChallengeSmoke failure_cap and five_attempts |
+| T-19.6-14 | Tampering | parallel verifies (attempt bypass, double mint) | high | mitigate | guarded attempt increment and single completion UPDATE (Pattern 2); TestChallengeSmoke parallel_verify yields one token |
+| T-19.6-15 | Spoofing | TOTP code replay within the window (Pitfall 5) | medium | mitigate | last accepted counter in metadata, refused at or below; TestTracerSmoke replay |
+| T-19.6-16 | Spoofing | email downgrade of a TOTP-only user (Pitfall 10) | high | mitigate | email/send and verify method=email only when the email method is enabled (D-18 A8); TestChallengeSmoke email_only_when_enabled |
+| T-19.6-17 | Denial of service | mail bombing through email/send | medium | mitigate | at most 3 sends per challenge in one guarded UPDATE, the bucket and the failure cap |
+| T-19.6-18 | Information disclosure | secrets at rest | high | mitigate | SHA-256 hex for challenge and trusted-device tokens, lagoon.Encrypted TOTP secret, bcrypt recovery and email codes (D-06); TestTracerSmoke and TestTrustedSettingsSmoke compare stored columns |
+| T-19.6-19 | Information disclosure | logs and error strings | medium | mitigate | handlers log only error types and user ids, never codes, tokens, secrets or passwords; 500 answers are generic |
+| T-19.6-20 | Spoofing | stolen trusted-device token | medium | mitigate | stored as SHA-256, bound to the user, 30-day expiry, off by default, revoked on any password change and on the last method's removal, listed and revocable per user (D-07, D-10) |
+| T-19.6-21 | Elevation of privilege | management routes touching another user's rows | high | mitigate | every query scoped by the user id from the verified JWT; {id} routes constrained to digits with Where directly after the route; 404 for foreign ids |
+| T-19.6-22 | Information disclosure | recovery codes after creation | medium | mitigate | codes shown once (first set D-19, regenerate), listing returns only a count and requires the password; the management group carries throttle:user-api |
+| T-19.6-23 | Denial of service | bcrypt CPU per wrong recovery code (Pitfall 9) | low | mitigate | input normalized and capped at 32 characters, stop at the first match, bucket and failure cap |
+| T-19.6-24 | Tampering | settings values (USER-005 type confusion, unknown methods or groups) | low | mitigate | typed mode with an allow-list in Rules, BeforeValidate allow-lists for methods and groups, unknown stored mode treated as optional |
+| T-19.6-25 | Spoofing | client-chosen TOTP secret at confirm (PHP parity) | low | accept | only for the caller's own account; base32 and length validated; a proof code from the secret is required |
+| T-19.6-SC | Tampering | Go module installs | high | mitigate | pquerna/otp v1.5.0 and boombuler/barcode v1.1.0 installed only after plan 01 Task 5's recorded approval (precondition); the archived YAML module is absent from `go list -deps` |
+
+
+
+- Task 1: TestQRSVGSmoke (short) and TestTracerSmoke; the note precedes the library require; no remote.
+- Task 2: TestChallengeSmoke and TestTracerSmoke.
+- Task 3: TestTrustedSettingsSmoke, TestChallengeSmoke and TestTracerSmoke.
+- Feedback sampling (19.6-VALIDATION.md): after each commit `go -C ../sm-mfa-plugin vet ./... && go -C ../sm-mfa-plugin test -short ./...`.
+
+
+
+- SC-1 (without WebAuthn): TOTP setup, confirm and disable; email codes; recovery codes; trusted devices, all with PHP's paths and bodies.
+- SC-2: login with a second factor answers the challenge; the challenge routes share the 2fa-verify bucket.
+- D-18: failure cap, email only when enabled, capped sends, mail expiry within the challenge.
+- D-12, D-13, D-15: settings screen with immediate effect, only mode disabled exempts, admin reset service published.
+
+
+
diff --git a/.planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-03-PLAN.md b/.planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-03-PLAN.md
new file mode 100644
index 00000000..0be15613
--- /dev/null
+++ b/.planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-03-PLAN.md
@@ -0,0 +1,446 @@
+---
+phase: 19.6-two-factor-auth-and-device-flow-in-sm-user-plugin
+plan: 03
+type: execute
+wave: 3
+depends_on: ["19.6-01", "19.6-02"]
+files_modified:
+ - ../sm-mfa-plugin/go.mod
+ - ../sm-mfa-plugin/go.sum
+ - ../sm-mfa-plugin/routes.go
+ - ../sm-mfa-plugin/config/config.yaml
+ - ../sm-mfa-plugin/lang/en/lang.yaml
+ - ../sm-mfa-plugin/lang/pl/lang.yaml
+ - ../sm-mfa-plugin/classes/config.go
+ - ../sm-mfa-plugin/classes/webauthn.go
+ - ../sm-mfa-plugin/classes/passwordless.go
+ - ../sm-mfa-plugin/classes/gate.go
+ - ../sm-mfa-plugin/classes/methods.go
+ - ../sm-mfa-plugin/classes/admin.go
+ - ../sm-mfa-plugin/classes/events.go
+ - ../sm-mfa-plugin/controllers/webauthn.go
+ - ../sm-mfa-plugin/controllers/passwordless.go
+ - ../sm-mfa-plugin/models/webauthn_credential.go
+ - ../sm-mfa-plugin/models/registry.go
+ - ../sm-mfa-plugin/updates/202610220006_create_golem15_user_webauthn_credentials.go
+ - ../sm-mfa-plugin/updates/registry.go
+ - ../sm-mfa-plugin/webauthn_authenticator_test.go
+ - ../sm-mfa-plugin/webauthn_smoke_test.go
+ - ../sm-mfa-plugin/passwordless_smoke_test.go
+ - ../sm-mfa-plugin/README.md
+ - ../sm-sociallogin-plugin/classes/flow.go
+ - ../sm-sociallogin-plugin/classes/codes.go
+ - ../sm-sociallogin-plugin/controllers/complete.go
+ - ../sm-sociallogin-plugin/controllers/register_complete.go
+ - ../sm-sociallogin-plugin/lang/en/lang.yaml
+ - ../sm-sociallogin-plugin/lang/pl/lang.yaml
+ - ../sm-sociallogin-plugin/social_gate_smoke_test.go
+ - ../sm-sociallogin-plugin/README.md
+ - ../sm-sociallogin-plugin/go.mod
+ - ../sm-sociallogin-plugin/go.sum
+ - ../sm-demo-app/vue-demo-app/app/lib/twoFactor.ts
+ - ../sm-demo-app/vue-demo-app/app/lib/twoFactor.test.ts
+ - ../sm-demo-app/vue-demo-app/app/composables/useDemoAuth.ts
+ - ../sm-demo-app/vue-demo-app/app/composables/useTwoFactor.ts
+ - ../sm-demo-app/vue-demo-app/app/pages/login.vue
+ - ../sm-demo-app/vue-demo-app/app/pages/account/security.vue
+ - ../sm-demo-app/vue-demo-app/app/components/SiteHeader.vue
+ - ../sm-demo-app/vue-demo-app/i18n/locales/en.json
+ - ../sm-demo-app/vue-demo-app/i18n/locales/pl.json
+ - ../sm-demo-app/.gitmodules
+ - ../sm-demo-app/plugins/golem15/mfa
+ - ../sm-demo-app/plugins/golem15/user
+ - ../sm-demo-app/plugins/golem15/sociallogin
+ - ../sm-demo-app/vue-demo-app
+ - ../sm-demo-app/go.mod
+ - ../sm-demo-app/go.sum
+ - ../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/mfa_test.go
+ - ../sm-demo-app/config/golem15.mfa.yaml
+ - ../sm-demo-app/deploy/env.example
+ - ../sm-demo-app/README.md
+ - ../sm-bm-app/.gitmodules
+ - ../sm-bm-app/plugins/golem15/mfa
+ - ../sm-bm-app/plugins/golem15/user
+ - ../sm-bm-app/plugins/golem15/sociallogin
+ - ../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/mfa_test.go
+ - ../sm-bm-app/README.md
+ - ../fonoteka.go/plugins/golem15/user
+ - ../fonoteka.go/plugins/golem15/fonoteka/phase07_coverage_test.go
+ - ../fonoteka.go/plugins/golem15/fonoteka/classes/hidden_marshal_test.go
+autonomous: false
+requirements: [SC-1, SC-2, SC-4, D-01, D-02, D-04, D-05, D-06, D-08, D-09, D-10, D-14, D-16, D-17, D-19, D-20]
+user_setup:
+ - service: git.golem15.com
+ why: "Create the golem15/sm-mfa-plugin repository and push and tag sm-user-plugin, sm-sociallogin-plugin, sm-mfa-plugin, vue-demo-app, sm-demo-app and sm-bm-app (D-17), only after the Task 4 go-ahead"
+ dashboard_config:
+ - task: "Approve the release train in Task 4; the executor runs tea repos create and git push under that approval"
+ location: "git.golem15.com, organisation golem15"
+estimate:
+ tokens: 360000
+ raw_tokens: 360000
+ tasks: 6
+ confidence: low
+must_haves:
+ truths:
+ - "D-04, D-08: github.com/go-webauthn/webauthn v0.18.2 is required only by sm-mfa-plugin; POST 2fa/webauthn/register/options and 2fa/webauthn/options answer {options: , challenge}; register takes {attestation, challenge, name?} and webauthn/verify takes {challenge_token, assertion, trust_device?} with the standard RegistrationResponseJSON and AuthenticationResponseJSON shapes; attestation none, timeout 60 s, user verification preferred for second-factor use; RP ID golem15.mfa.webauthn.rp_id or the app.url host, RP name the app name, origins golem15.mfa.webauthn.origins or the app.url and golem15.user.frontend_url origins."
+ - "D-05, D-06: the challenge's WebAuthn state lives server-side (the expected challenge in the challenge row's code column; registration and passkey sessions in cooler, consumed once with Pull); a client-sent challenge is only a lookup key; register answers PHP's {message, credential:{id, name, created_at}, status} (plus recovery_codes when it created the first set, D-19); webauthn/verify answers {token, user} (plus trusted_device_token when asked and enabled) or 401 {\"error\":\"WebAuthn verification failed.\",\"two_factor_failed\":true}; DELETE webauthn/{id} answers PHP's bodies and the last credential's removal disables the method with the last-method cleanup."
+ - "D-16: golem15_user_webauthn_credentials keeps PHP's columns (credential_id, public_key, attestation_type, transports, sign_count, name, is_enabled, last_used_at) and adds credential_data (the library's Credential JSON, written back after every assertion with sign count and flags) and passwordless, with a unique index on credential_id; status lists webauthn_credentials as PHP's {id, name, last_used_at, created_at}; admin reset deletes the credentials."
+ - "D-14: POST /_user/api/v1/2fa/webauthn/passwordless/options and .../verify sit in the 2fa-verify bucket, answer 403 {\"error\":\"Passwordless login is not available.\"} while two_factor_passwordless_login is off, accept only enabled credentials registered as passkeys (resident key required, user verification required at registration and at login, credProps rk reported true), enforce activation, ban and suspension, fire the login event with Source passkey and issue a token without a second challenge."
+ - "D-09: sm-sociallogin-plugin consults the login gate in signIn before FinishLogin (Source social); with the mfa toggle off it behaves as v0.1.2; with it on, oauth-complete and oauth-register-complete answer 200 with the two_factor_required body instead of {token, user, action, return_to}; a refusing gate result redirects with the two_factor_unavailable message; gate errors fail closed; action=link is never gated."
+ - "D-20: the demo site (vue-demo-app, mounted in sm-demo-app) lets a signed-in user set up TOTP with the QR code and a confirm code, shows the first recovery codes once, disables TOTP, and answers a login challenge with a TOTP or recovery code; sm-bm-app stays API-only."
+ - "D-17: after the Task 4 go-ahead the remote operations run in order: sm-user-plugin pushed and tagged, then sm-mfa-plugin (new repository, tagged v0.1.0) and sm-sociallogin-plugin pushed and tagged against it, then vue-demo-app pushed; sm-demo-app and sm-bm-app mount golem15.mfa and bump the user and sociallogin pointers in separate commits; fonoteka.go bumps the user pointer and updates TestNoDeferredRoutesResolve (drops /devices, keeps 2fa) and its user-model count in that commit, does not mount mfa, links no WebAuthn module and is never pushed."
+ - "D-01: an application without golem15.mfa (fonoteka.go) has no /2fa route, no 2FA table and no go-webauthn, pquerna/otp or boombuler module in its module graph."
+ artifacts:
+ - path: "../sm-mfa-plugin/classes/webauthn.go"
+ provides: "per-request go-webauthn instance, user adapter, ceremony state"
+ contains: "webauthn.New"
+ - path: "../sm-mfa-plugin/classes/passwordless.go"
+ provides: "D-14 passkey login"
+ contains: "ValidatePasskeyLogin"
+ - path: "../sm-mfa-plugin/updates/202610220006_create_golem15_user_webauthn_credentials.go"
+ provides: "golem15_user_webauthn_credentials"
+ contains: "credential_data"
+ - path: "../sm-sociallogin-plugin/classes/flow.go"
+ provides: "D-09 gate in signIn"
+ contains: "CheckLoginGates"
+ - path: "../sm-demo-app/vue-demo-app/app/pages/account/security.vue"
+ provides: "D-20 TOTP setup, recovery codes, disable"
+ contains: "totp/setup"
+ - path: "../sm-demo-app/mfa_test.go"
+ provides: "TestMFAMounted"
+ contains: "TestMFAMounted"
+ key_links:
+ - from: "../sm-mfa-plugin/controllers/webauthn.go"
+ to: "modules/cooler/cooler.go"
+ via: "registration state consumed once with cooler.Cache.Pull"
+ pattern: "Pull\\("
+ - from: "../sm-sociallogin-plugin/classes/flow.go"
+ to: "../fonoteka.go/plugins/golem15/user/controllers/login_gates.go"
+ via: "signIn calls usercontrollers.CheckLoginGates with Source social before FinishLogin"
+ pattern: "LoginSourceSocial"
+ - from: "../sm-demo-app/vue-demo-app/app/composables/useDemoAuth.ts"
+ to: "/_user/api/v1/2fa/verify"
+ via: "the login page completes a challenge through verify or recovery"
+ pattern: "2fa/verify"
+ - from: "../sm-demo-app/summer.yaml"
+ to: "../sm-mfa-plugin/plugin.go"
+ via: "golem15.mfa mounted, plugins.gen.go regenerated"
+ pattern: "golem15.mfa"
+ prohibitions:
+ - statement: "No assertion, attestation, challenge, token, code or library error detail is logged or echoed; registration failures answer fixed reasons."
+ status: unresolved
+ verification: judgment
+ - statement: "The demo renders the server's SVG as an image data URL, never through v-html."
+ status: unresolved
+ verification: judgment
+ - statement: "Nothing is pushed, tagged, created remotely or deployed before the Task 4 reply; nothing is ever force-pushed; fonoteka.go is never pushed; no application is deployed."
+ status: unresolved
+ verification: judgment
+ - statement: "fonoteka.go does not mount golem15.mfa; sm-bm-app's frontend gets no 2FA UI (D-17, D-20)."
+ status: unresolved
+ verification: judgment
+ - statement: "The stale, remote-less ../vue-demo-app checkout is not used; the demo UI is committed in ../sm-demo-app/vue-demo-app (origin golem15/vue-demo-app)."
+ status: unresolved
+ verification: judgment
+---
+
+
+
+
+## Phase Goal
+
+ROADMAP Goal (not in user-story format; no story invented, see plan 01): `sm-user-plugin` has the PHP user plugin's second factors and device login. CONTEXT D-01 puts the second factors in `sm-mfa-plugin`; D-14 adds passkey login, D-09 the social-login gate, D-17 the adoption and D-20 the demo UI.
+
+This plan's slice (plan 3 of 4, wave 3, "WebAuthn and adoption"): the WebAuthn second factor (tracer), passwordless passkey login and the social-login gate, the demo's two-factor screens, then the release go-ahead, the publishing in D-17 order and the adoption in sm-demo-app, sm-bm-app and fonoteka.go.
+
+## 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-20.
+
+## Decisions taken in this plan (recorded assumptions and Claude's discretion)
+
+- Passwordless paths (D-14 discretion): `POST /_user/api/v1/2fa/webauthn/passwordless/options` (body `{}`) and `POST /_user/api/v1/2fa/webauthn/passwordless/verify` (body `{challenge, assertion}`), in the challenge group with the 2fa-verify bucket. While the setting is off both answer 403 `{"error":"Passwordless login is not available."}` (the "X is not available." wording of PHP's management errors).
+- Registration takes an optional `passwordless` boolean (with the setting on) to request a passkey: resident key required, user verification required and the credProps extension. A credential is stored with `passwordless = true` only when it was requested, the authenticator data's UV flag is set and credProps reports rk true; without credProps the credential stays second-factor-only (fail closed).
+- User handle: the 8-byte big-endian user id, as PHP's `hex2bin(str_pad(dechex(id), 16, '0', STR_PAD_LEFT))` (RESEARCH A4), so a later import maps; it reveals the numeric id to the authenticator (accepted, T-19.6-34).
+- Credential storage: `credential_data` TEXT holding the JSON of `webauthn.Credential` (RESEARCH A3), plus PHP's display columns; written back after every successful assertion (Pitfall 8). A clone warning (non-zero sign counts that do not increase) refuses the login with the generic failure and logs the credential row id.
+- Ceremony state (Pitfall 6, Pattern 3): login 2FA stores the base64url challenge in the challenge row's `code` with `method = webauthn` (rebuilt into SessionData at verify); registration and passkey sessions are JSON in `cooler` under `mfa:webauthn:register::` and `mfa:webauthn:passwordless:-:`, TTL 120 s, consumed with `Pull`; the memory driver works within one instance only (A11, README note).
+- The RP: `golem15.mfa.webauthn.rp_id` (default the app.url host), `golem15.mfa.webauthn.rp_name` (default app.name), `golem15.mfa.webauthn.origins` (default the app.url origin and the `golem15.user.frontend_url` origin). An IP-address host cannot run a ceremony in browsers (Pitfall 7); the README says so. The demo serves the SPA and the API on one host in production, so the defaults fit; locally (127.0.0.1) WebAuthn is not usable, which D-20 allows.
+- Social sign-in (D-09, RESEARCH Q5): the gate runs in `signIn` before `FinishLogin` so a deactivated account is not restored before the second factor (D-18 A10); `CompletionPayload` and `Outcome` gain `Challenge map[string]any`; `Complete` and `RegisterComplete` answer it as 200 JSON (the token cookie option does not apply to it); a non-200 gate result becomes a FlowError with the new message key `oauth.two_factor_unavailable`. Trusted devices never skip a social challenge (A7).
+- Demo UI location: CONTEXT D-20 names `../vue-demo-app`, but that sibling is a stale local copy with no remote (2 commits, `32049f7`); the real `golem15/vue-demo-app` repository is the submodule checkout `../sm-demo-app/vue-demo-app` (origin set, `5a8d544`, vitest configured). The UI is committed there and reaches sm-demo-app through a pointer bump. The QR SVG is shown through an `img` data URL. Email codes, WebAuthn and trusted devices in the demo UI are left out (optional in D-20).
+- Release (D-17, CONTEXT discretion): one go-ahead (Task 4) covers every remote operation of the phase, including plan 04's possible patch releases only if Task 4's reply says so. Tag numbers are chosen in the reply: sm-user-plugin the next patch after its newest tag on origin (v0.1.6 unless Phase 19.8 released one first), sm-sociallogin-plugin the next patch (v0.1.3), sm-mfa-plugin v0.1.0. No framework tag is needed: sm-mfa-plugin requires the framework version sm-sociallogin-plugin requires (v0.3.1, the first tag with `cooler.Cache.Pull`), so mounting it does not move an application's framework pin; a newer tag only if the code needs one, named in the SUMMARY. sm-demo-app's `TestGoModRequiresTags` needs tagged requires, so a push without tags cannot mount mfa in the demo.
+- Concurrency: Phase 19.8 plan 03 Task 7 may be bumping pointers in sm-demo-app and sm-bm-app; before each commit there, `git status --porcelain` is checked for edits to the files this task touches, and the executor stops and reports when one is another session's.
+- Context budget (estimate 360k, 6 tasks; accepted under the four-plan decision): Tasks 1-3 run in the first context; the Task 4 checkpoint ends it; a continuation agent runs Tasks 5 and 6 from this plan, the 19.6-01/02 SUMMARYs and the `19.6-03-SUMMARY.md` draft written at the checkpoint (`Status: draft (checkpoint Task 4)`, commits with hashes per repository, NOT RUN notes).
+
+## Artifacts this phase produces (this plan)
+
+- sm-mfa-plugin: module requirement `github.com/go-webauthn/webauthn v0.18.2`; classes `WebAuthnFor(ctx context.Context, app *backpack.App) (*webauthn.WebAuthn, error)`, `UserHandle(id uint) []byte`, `NewWebAuthnUser`, `BeginRegistration`, `FinishRegistration`, `BeginAssertion`, `FinishAssertion`, `BeginPasswordless`, `FinishPasswordless`; models `WebAuthnCredential` (table `golem15_user_webauthn_credentials`); migration `202610220006_create_golem15_user_webauthn_credentials`; controllers `WebAuthnRegisterOptions`, `WebAuthnRegister`, `WebAuthnRemove`, `WebAuthnOptions`, `WebAuthnVerify`, `PasswordlessOptions`, `PasswordlessVerify`; events `WebAuthnRegisteredEvent` (`golem15.user.2fa.webauthn.registered`), `WebAuthnRemovedEvent` (`golem15.user.2fa.webauthn.removed`); config keys `golem15.mfa.webauthn.rp_id`, `golem15.mfa.webauthn.rp_name`, `golem15.mfa.webauthn.origins`; routes `POST /_user/api/v1/2fa/webauthn/options`, `POST .../2fa/webauthn/verify`, `POST .../2fa/webauthn/passwordless/options`, `POST .../2fa/webauthn/passwordless/verify` (2fa-verify bucket) and `POST .../2fa/webauthn/register/options`, `POST .../2fa/webauthn/register`, `DELETE .../2fa/webauthn/{id}` (jwt.auth); test helper `virtualAuthenticator`; smoke tests `TestWebAuthnCeremonySmoke`, `TestWebAuthnSmoke`, `TestPasswordlessSmoke`; remote `git@git.golem15.com:golem15/sm-mfa-plugin.git`, tag `v0.1.0`.
+- sm-sociallogin-plugin: field `classes.CompletionPayload.Challenge`, field `classes.Outcome.Challenge`, lang key `golem15.sociallogin::lang.oauth.two_factor_unavailable`, smoke test `TestSocialGateSmoke`, the next patch tag.
+- sm-user-plugin: the next patch tag carrying plan 01.
+- vue-demo-app: `app/lib/twoFactor.ts` (`isTwoFactorChallenge`, `normalizeRecoveryCode`, `isTotpCode`, `challengeExpired`, `svgDataUrl`, types `TwoFactorChallenge`, `TwoFactorStatus`, `TotpSetup`), `app/composables/useTwoFactor.ts` (`useTwoFactor`), `useDemoAuth` gaining `verifyChallenge` and `recoverChallenge` and a challenge-aware `login`, page `/account/security`, header link, i18n keys `auth.twoFactor.*` and `security.*`.
+- sm-demo-app: submodule `plugins/golem15/mfa`, `config/golem15.mfa.yaml`, `TestMFAMounted` (mfa_test.go), plugin row in README; sm-bm-app: submodule `plugins/golem15/mfa`, `TestMFAMounted` (mfa_test.go); fonoteka.go: updated `TestNoDeferredRoutesResolve` and user-model count.
+
+
+Add WebAuthn as a second factor and as passwordless passkey login, put social sign-in through the gate behind its setting, give the demo its two-factor screens, then, under the go-ahead, publish in D-17 order and mount the plugin in the demo and bm applications while fonoteka.go moves to the new user plugin.
+
+Purpose: D-04 (go-webauthn), D-05, D-06, D-08, D-09, D-10 (WebAuthn verify), D-14, D-16 (sixth table), D-17, D-19 (register), D-20; SC-1 (WebAuthn registration and login challenges), SC-4.
+Output: commits in sm-mfa-plugin, sm-sociallogin-plugin, vue-demo-app, sm-demo-app, sm-bm-app and fonoteka.go; after the reply the repository, tags and pushes listed in Task 5.
+
+
+
+@~/.claude/msd-core/workflows/execute-plan.md
+@~/.claude/msd-core/templates/summary.md
+
+
+
+@.planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-CONTEXT.md
+@.planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-RESEARCH.md
+@.planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-PATTERNS.md
+@.planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-01-SUMMARY.md
+@.planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-02-SUMMARY.md
+
+Repos and concurrency: this plan writes sm-mfa-plugin (../sm-mfa-plugin), sm-sociallogin-plugin (../sm-sociallogin-plugin, at v0.1.2 `9fd58db`), the vue-demo-app submodule checkout (../sm-demo-app/vue-demo-app, origin golem15/vue-demo-app, at `5a8d544`), sm-demo-app, sm-bm-app and fonoteka.go, and pushes sm-user-plugin from ../fonoteka.go/plugins/golem15/user. Shared plugins are committed and pushed before the pointer bumps in applications (CLAUDE.md, core-plugins-own-repos.md); never stage files inside a submodule from the application repository; use `ssu` or plain `git submodule` for submodule moves. In fonoteka.go stage only `plugins/golem15/user` and the test files this plan names (its feedback and golem pointer moves belong to other sessions); fonoteka.go is never pushed. sm-bm-app has untracked planning files from other sessions: stage by explicit path. No co-author tags; one logical change per commit. Docker-backed tests skip under -short; run them through `sudo -u golem` if the socket is denied; record NOT RUN when containers are unreachable.
+
+PHP source of truth (read-only, /media/nvme/dev/jz/wavepath.org/plugins/golem15/user): controllers/TwoFactorApiController.php (:146-218 WebAuthn challenge, :300-470 WebAuthn management), classes/twofactor/WebAuthnMethod.php (:18-20, :125-142, :170-179, :217-220, :316-321), classes/twofactor/TwoFactorService.php (:322-356 removeWebAuthnCredential, :549-594 status), models/WebAuthnCredential.php, updates/v3.0.0 create_webauthn_credentials_table.php, components/TwoFactor.php (:278, :293 passwordless handlers). Clients: /media/nvme/dev/jz/wavepath.org/vue-wavepath-app/src/types/auth.ts (:157-162 TwoFactorChallenge type).
+
+
+- sm-mfa-plugin after plan 02 (19.6-02-SUMMARY.md has the final names): `classes.LoadEffective`, `classes.FindChallenge`, `classes.BeginAttempt`, `classes.CompleteChallenge`, `classes.FailureCapReached`, `classes.CompleteLogin`, `classes.EnableMethod`, `classes.DisableMethod`, `classes.Status`, `classes.IssueTrustedDevice`, `classes.Admin`, the ensure-recovery-codes helper, the events, `controllers` helpers (writeJSON, writeValidation, the jwt principal helper), `models.TwoFactorMethod`, `models.MethodWebAuthn`, routes.go groups.
+- go-webauthn v0.18.2 (RESEARCH Q6): `webauthn.New(&webauthn.Config{RPID, RPDisplayName, RPOrigins, AttestationPreference, Timeouts})`, `BeginRegistration(user, opts...)`, `CreateCredential(user, session, parsed)`, `BeginLogin(user, opts...)`, `ValidateLogin(user, session, parsed)`, `BeginDiscoverableLogin(opts...)`, `ValidatePasskeyLogin(handler, session, parsed)`, `protocol.ParseCredentialCreationResponseBytes`, `protocol.ParseCredentialRequestResponseBytes`, `webauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementRequired)`, `webauthn.WithUserVerification(protocol.VerificationRequired|VerificationPreferred)`, `webauthn.WithExclusions`, `webauthn.WithAllowedCredentials`, `webauthn.WithConveyancePreference(protocol.PreferNoAttestation)`, `webauthn.WithExtensions`, `webauthn.User` (WebAuthnID, WebAuthnName, WebAuthnDisplayName, WebAuthnCredentials), `webauthn.SessionData`, `webauthn.Credential` (JSON round-trip).
+- sm-sociallogin-plugin v0.1.2: classes/flow.go `signIn` :311-324 (FinishLogin then IssueToken and APIArray), `linkIdentity` (never gated), `Outcome`; classes/codes.go `CompletionPayload` :35-40, `PutCompletion`, `PullCompletion`; controllers/complete.go :17-35 (`writeSession`); controllers/register_complete.go; controllers/callback.go (FlowError redirect); classes/messages.go and lang files; tracer_smoke_test.go and harness_test.go (`bootSocial`, fake providers through the Registry).
+- sm-user-plugin after plan 01: `usercontrollers.CheckLoginGates`, `userclasses.LoginAttempt`, `userclasses.LoginSourceSocial`, `userclasses.LoginSourcePasskey`, `usercontrollers.FinishLogin`, `IssueToken`, `APIArray`, `FireLoginEvent`, `userclasses.RejectIfThrottled`.
+- vue-demo-app (../sm-demo-app/vue-demo-app): app/composables/useDemoAuth.ts (login, register, logout, formErrors, authErrors), app/pages/login.vue (submit, busy, error, next guard, Turnstile widget), app/pages/subscriptions.vue (logged-out notice), app/components/SiteHeader.vue (:9-47 links and logout), app/utils/authCookie.ts (DEMO_TOKEN_COOKIE), app/assets/css/site.css (classes wrap, section, panel, form, button, form__error, notice, muted, link-button), i18n/locales/en.json and pl.json, vitest.config.ts (Node tests under app/**/*.test.ts), nuxt.config.ts (`/_user` already proxied in dev).
+- sm-demo-app: summer.yaml, go.work, go.mod (TestGoModRequiresTags in go_mod_test.go), .gitmodules, boot_test.go (`wantPlugins`, `TestBootAllPlugins`, `routed`), sociallogin_test.go (`socialServer` boot without a database), plugins.gen.go, deploy/env.example, README.md, config/app.yaml (`url: http://127.0.0.1:8097`, `jwt.blacklist_grace: 10`), DEPLOY.md (same-host nginx routing on demo.summercms.io); mount precedent commit `d5a98d9` (`git -C ../sm-demo-app show --stat d5a98d9`), bump precedents `1afd7ff`, `f16ef6c`.
+- sm-bm-app: summer.yaml, go.mod (`v0.0.0` requires with `replace => ./plugins/...`), go.work, .gitmodules, sociallogin_test.go (`TestSocialLoginMounted`), realtime_test.go (boot harness), plugins.gen.go; mount precedent commit `117ef8f`, bump precedents `cc4b23f`, `1e09c23`.
+- fonoteka.go: plugins/golem15/fonoteka/phase07_coverage_test.go :79-112 (`banned` list), plugins/golem15/fonoteka/classes/hidden_marshal_test.go (`expectedUserModels`), parity/schema_diff_test.go (Go-only table allowances), plugins/golem15/fonoteka/console/dev_seed_test.go and dev_stack_smoke_test.go (table lists); precedent `7fc790d` (a user-plugin model added with the count bumped).
+
+
+
+
+
+
+ Task 1: Tracer: a signed-in user registers a security key, the next login lists webauthn, and a WebAuthn assertion against server-side options completes the challenge with {token, user}
+ D-08: the standard base64url WebAuthn request and response shapes become what clients implement; changing them later breaks every client built against them.
+ ../sm-mfa-plugin/go.mod, ../sm-mfa-plugin/go.sum, ../sm-mfa-plugin/routes.go, ../sm-mfa-plugin/config/config.yaml, ../sm-mfa-plugin/classes/config.go, ../sm-mfa-plugin/classes/webauthn.go, ../sm-mfa-plugin/classes/gate.go, ../sm-mfa-plugin/classes/methods.go, ../sm-mfa-plugin/classes/admin.go, ../sm-mfa-plugin/classes/events.go, ../sm-mfa-plugin/controllers/webauthn.go, ../sm-mfa-plugin/models/webauthn_credential.go, ../sm-mfa-plugin/models/registry.go, ../sm-mfa-plugin/updates/202610220006_create_golem15_user_webauthn_credentials.go, ../sm-mfa-plugin/updates/registry.go, ../sm-mfa-plugin/webauthn_authenticator_test.go, ../sm-mfa-plugin/webauthn_smoke_test.go, ../sm-mfa-plugin/README.md
+ 19.6-02-SUMMARY.md, ../sm-mfa-plugin/classes/challenge.go, ../sm-mfa-plugin/classes/gate.go, ../sm-mfa-plugin/classes/methods.go, ../sm-mfa-plugin/controllers/challenge.go, ../sm-mfa-plugin/controllers/management.go, ../sm-mfa-plugin/routes.go, ../sm-sociallogin-plugin/classes/codes.go (:171-215 cooler Set and Pull), modules/cooler/cooler.go, go-webauthn v0.18.2 sources in the module cache (webauthn/types.go, registration.go, login.go, credential.go, types_session.go, doc.go; protocol/options.go, authenticator.go, credential.go, assertion.go), 19.6-RESEARCH.md (Q6, Pattern 3, Code Examples 5 and 6, Pitfalls 6, 7, 8, Assumptions A3, A4, A11, A12), /media/nvme/dev/jz/wavepath.org/plugins/golem15/user/classes/twofactor/WebAuthnMethod.php, /media/nvme/dev/jz/wavepath.org/plugins/golem15/user/controllers/TwoFactorApiController.php (:146-218, :380-470)
+ Per D-04, D-05, D-06, D-08, D-16 (sixth table), D-19. Re-check the pin first when today is after 2026-11-20 (`go list -m -json github.com/go-webauthn/webauthn@latest`); install exactly `go get github.com/go-webauthn/webauthn@v0.18.2` unless the user approves another version, then `go mod tidy`; plugin code never imports the archived YAML v3 module.
+
+Migration `golem15_user_webauthn_credentials` (id BIGSERIAL PK, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, credential_id TEXT NOT NULL (base64url raw id) with a unique index, public_key TEXT NOT NULL (base64url COSE key), attestation_type VARCHAR(255) NULL, transports JSONB NULL, sign_count BIGINT NOT NULL DEFAULT 0, name VARCHAR(255) NULL, is_enabled BOOLEAN NOT NULL DEFAULT TRUE, last_used_at TIMESTAMPTZ NULL, credential_data TEXT NULL, passwordless BOOLEAN NOT NULL DEFAULT FALSE, created_at, updated_at, index (user_id)) and model `WebAuthnCredential` (CredentialData `json:"-"`).
+
+classes/webauthn.go: config keys rp_id, rp_name, origins in classes/config.go and config/config.yaml with the defaults in Decisions; `WebAuthnFor(ctx, app)` builds a `webauthn.New` per call (RESEARCH Code Example 5: AttestationPreference none, Login and Registration timeouts 60 s enforced) so settings and config changes apply without restart; `UserHandle(id)` 8 bytes big-endian; a user adapter implementing `webauthn.User` (WebAuthnID the handle, name and display name the user's email and name, credentials decoded from the enabled rows' credential_data). `BeginRegistration(ctx, app, cache, user, passwordless bool)`: exclusions of the user's existing credentials, conveyance none, user verification preferred, and for a passkey resident key required, user verification required and the credProps extension; store the SessionData JSON plus the passwordless flag in cooler under `mfa:webauthn:register::` for 120 s; return `{"options": creation, "challenge": session.Challenge}`. `FinishRegistration(ctx, app, cache, db, user, challenge, attestationJSON, name)`: `Pull` the session (miss or other user → error), `protocol.ParseCredentialCreationResponseBytes`, `CreateCredential`, insert the row (credential_id and public_key base64url, attestation format, transports, sign count, name or "Security Key", credential_data, passwordless per Decisions). `BeginAssertion(ctx, app, db, challengeRow, user)`: allowed credentials from the user's enabled rows, user verification preferred, store `session.Challenge` in the challenge row's code with method webauthn through a guarded UPDATE (uncompleted, unexpired). `FinishAssertion(ctx, app, tx, challengeRow, user, assertionJSON)`: rebuild SessionData from the row (Challenge, RelyingPartyID, UserID handle, AllowedCredentialIDs from the database, UserVerification preferred, Expires the challenge's expires_at), parse with `protocol.ParseCredentialRequestResponseBytes`, `ValidateLogin`, refuse a clone warning, write the updated Credential back (credential_data, sign_count, last_used_at). Library errors are mapped to fixed reasons and never returned to clients or logged with their text.
+
+controllers/webauthn.go: `WebAuthnRegisterOptions` (jwt.auth; 403 `{"error":"WebAuthn is not available."}` unless webauthn is available; `passwordless` honoured only with two_factor_passwordless_login on), `WebAuthnRegister` (fields attestation required object, challenge required string, name nullable string max 255; failures answer 422 `{"error":"Registration failed: "}` with reasons such as "invalid or expired challenge" and "verification failed"; success upserts and enables the webauthn method row, runs the ensure-recovery-codes rule (D-19), fires WebAuthnRegisteredEvent and answers `{"message":"Security key registered successfully.","credential":{"id","name","created_at"},"status":...}` plus recovery_codes when a set was created), `WebAuthnRemove` (DELETE webauthn/{id} scoped by user id; 404 `{"error":"Credential not found."}`; deleting the last enabled credential disables the method, fires DisabledEvent and runs the last-method cleanup; WebAuthnRemovedEvent; `{"message":"Security key removed.","status":...}`), `WebAuthnOptions` (unauthenticated, PHP validation; FindChallenge else 401 `{"error":"Invalid or expired challenge."}`, missing user 401 `{"error":"Invalid challenge."}`, failure cap 401; answers `{options, challenge}`), `WebAuthnVerify` (validation `challenge_token` size 64 and `assertion` required object; BeginAttempt; a row without method webauthn or code → 401 `{"error":"Invalid or expired challenge. Request WebAuthn options first."}`; FinishAssertion failure → FailedEvent and 401 `{"error":"WebAuthn verification failed.","two_factor_failed":true}`; success → CompleteChallenge, CompleteLogin, VerifiedEvent with method webauthn, trusted_device_token per D-10). Status lists webauthn_credentials `{id, name (default "Security Key"), last_used_at, created_at}` with wire.Time; `EnabledMethods` keeps webauthn only with an enabled credential (PHP); Admin.Reset and HasTwoFactor include the credentials (delete them; a credential counts). Routes: challenge group `g.Post("/webauthn/options", ...)`, `g.Post("/webauthn/verify", ...)`; management group `g.Post("/webauthn/register/options", ...)`, `g.Post("/webauthn/register", ...)`, `g.Delete("/webauthn/{id}", ...)` followed directly by `g.Where("id", "[0-9]+")`.
+
+Test helper webauthn_authenticator_test.go: `virtualAuthenticator` holding an ECDSA P-256 key, a random credential id, a sign counter and flags (UP, UV, BE, BS settable), producing the RegistrationResponseJSON (attestation format none, attested credential data with a zero AAGUID and the COSE EC2 key, clientDataJSON type webauthn.create with the challenge and origin) and the AuthenticationResponseJSON (authenticator data with the RP ID hash, flags and counter, clientDataJSON type webauthn.get, ES256 signature over authData plus SHA-256 of clientDataJSON, userHandle when given), encoding CBOR with go-webauthn's own CBOR helper package (or fxamacker/cbor/v2, already in the graph through go-webauthn, if the helper is not exported). Smoke tests: `TestWebAuthnCeremonySmoke` (no database, -short: WebAuthnFor with rp_id localhost and origin http://localhost:3000; registration options carry attestation none and the challenge; FinishRegistration's library path accepts the virtual authenticator's response; an assertion with a counter that went backwards is refused as a clone; an assertion with BE changed is refused). `TestWebAuthnSmoke` (Postgres, rp_id localhost, origins http://localhost:3000): register options, register with the virtual authenticator (name "Laptop key") answers the PHP message with credential and recovery_codes; status lists the credential; login answers available_methods containing webauthn; webauthn/verify before options answers the "Request WebAuthn options first." 401; options then verify answers a token and the stored sign_count advanced; a second register with the same challenge fails (state consumed); DELETE webauthn/{id} of the only credential disables the method and the next login answers a token for a user without other methods. README (same commit): WebAuthn section (shapes per D-08, RP ID and origins, IP hosts refused, Redis needed for multi-instance ceremonies, PHP credentials not importable as-is). Commit: `feat: WebAuthn security keys as a second factor`.
+
+ go -C ../sm-mfa-plugin vet ./... && go -C ../sm-mfa-plugin test -count=1 -short ./... && go -C ../sm-mfa-plugin test -count=1 -short -run '^TestWebAuthnCeremonySmoke$' -v . 2>&1 | grep -q -- '^--- PASS: TestWebAuthnCeremonySmoke'
+ Non-zero exit: vet or a short test is red, or no "--- PASS: TestWebAuthnCeremonySmoke" line (the virtual authenticator's responses are refused or the clone and BE checks do not hold).
+ go -C ../sm-mfa-plugin test -count=1 -run '^(TestWebAuthnSmoke|TestTracerSmoke|TestChallengeSmoke|TestTrustedSettingsSmoke)$' -v . 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 4
+ The count of top-level "--- PASS: Test" lines is not 4: the WebAuthn path failed, an earlier smoke regressed, or a test skipped because Docker was unreachable (rerun through sudo -u golem, else record NOT RUN).
+
+
+ - `grep -c 'github.com/go-webauthn/webauthn v0.18.2' ../sm-mfa-plugin/go.mod` prints 1 (or the version the user approved, recorded in the SUMMARY).
+ - `grep -c 'credential_data' ../sm-mfa-plugin/updates/202610220006_create_golem15_user_webauthn_credentials.go` prints at least 1 and `grep -c 'passwordless' ../sm-mfa-plugin/updates/202610220006_create_golem15_user_webauthn_credentials.go` prints at least 1.
+ - `grep -c 'Pull(' ../sm-mfa-plugin/classes/webauthn.go` prints at least 1 and `grep -c 'PreferNoAttestation' ../sm-mfa-plugin/classes/webauthn.go` prints at least 1.
+ - `grep -A1 '"/webauthn/{id}"' ../sm-mfa-plugin/routes.go | grep -c 'Where("id", "\[0-9\]+")'` prints 1.
+ - `grep -c '"WebAuthn verification failed."' ../sm-mfa-plugin/controllers/webauthn.go` prints at least 1 and `grep -c 'Request WebAuthn options first.' ../sm-mfa-plugin/controllers/webauthn.go` prints 1.
+ - `D=$(go -C ../sm-mfa-plugin list -deps .) && test "$(printf '%s\n' "$D" | grep -c '^gopkg.in/yaml')" -eq 0` exits 0.
+
+ A user can add a security key and sign in with it as their second factor, with all ceremony state held on the server.
+
+
+
+ Task 2: A user who registered a passkey signs in with it alone when the site allows passwordless login, and social sign-in asks for the second factor when the site turns that on
+ D-14: once users register passkeys and sign in without a password, removing the passwordless routes strands them.
+ ../sm-mfa-plugin/classes/passwordless.go, ../sm-mfa-plugin/controllers/passwordless.go, ../sm-mfa-plugin/routes.go, ../sm-mfa-plugin/lang/en/lang.yaml, ../sm-mfa-plugin/lang/pl/lang.yaml, ../sm-mfa-plugin/passwordless_smoke_test.go, ../sm-mfa-plugin/README.md, ../sm-sociallogin-plugin/classes/flow.go, ../sm-sociallogin-plugin/classes/codes.go, ../sm-sociallogin-plugin/controllers/complete.go, ../sm-sociallogin-plugin/controllers/register_complete.go, ../sm-sociallogin-plugin/lang/en/lang.yaml, ../sm-sociallogin-plugin/lang/pl/lang.yaml, ../sm-sociallogin-plugin/social_gate_smoke_test.go, ../sm-sociallogin-plugin/README.md
+ ../sm-mfa-plugin/classes/webauthn.go and controllers/webauthn.go (Task 1), ../sm-mfa-plugin/webauthn_authenticator_test.go, ../fonoteka.go/plugins/golem15/user/classes/throttle.go (RejectIfThrottled :67), ../fonoteka.go/plugins/golem15/user/controllers/login_gates.go, ../sm-sociallogin-plugin/classes/flow.go (signIn :311-324, linkIdentity, Outcome), ../sm-sociallogin-plugin/classes/codes.go, ../sm-sociallogin-plugin/controllers/complete.go, ../sm-sociallogin-plugin/controllers/register_complete.go, ../sm-sociallogin-plugin/controllers/callback.go, ../sm-sociallogin-plugin/classes/messages.go, ../sm-sociallogin-plugin/tracer_smoke_test.go, ../sm-sociallogin-plugin/harness_test.go, ../sm-sociallogin-plugin/README.md, /media/nvme/dev/jz/wavepath.org/plugins/golem15/user/components/TwoFactor.php (:278, :293), 19.6-RESEARCH.md (Q5 sociallogin, Q6 passwordless, Code Example 6, Security Domain, Assumption A7)
+ D-14 in sm-mfa-plugin. classes/passwordless.go: `BeginPasswordless(ctx, app, cache)` with `BeginDiscoverableLogin(webauthn.WithUserVerification(protocol.VerificationRequired))`, session JSON in cooler under `mfa:webauthn:passwordless:-:` for 120 s, returning `{options, challenge}`; `FinishPasswordless(ctx, app, cache, db, challenge, assertionJSON, ip)` per RESEARCH Code Example 6: Pull the session (miss → failure); `ValidatePasskeyLogin` with a handler that loads the credential by the base64url raw id and refuses it unless it exists, is enabled, has passwordless true and its user's handle equals the returned userHandle; refuse a clone warning; write the credential back; then the user (soft-deleted ones included through Unscoped) must be activated and pass `userclasses.RejectIfThrottled` (ban and suspension); any refusal is one generic failure. controllers/passwordless.go: `PasswordlessOptions` and `PasswordlessVerify` (fields challenge required string, assertion required object); both answer 403 `{"error":"Passwordless login is not available."}` while `two_factor_passwordless_login` is off (read per request); a failure answers 401 `{"error":"WebAuthn verification failed."}`; success runs `usercontrollers.FinishLogin` (restores a deactivated account, as a full password-plus-second-factor login would), `IssueToken`, `APIArray`, `FireLoginEvent` with `userclasses.LoginSourcePasskey`, and answers `{token, user}`; no gate, no second challenge (D-14). Routes in the challenge group (2fa-verify bucket): `g.Post("/webauthn/passwordless/options", ...)`, `g.Post("/webauthn/passwordless/verify", ...)`. README (same commit): Passkeys section (setting, passkey registration with passwordless true, the UV and resident-key rules, activation, ban and suspension checks, no second challenge, the security review in plan 04). Smoke test passwordless_smoke_test.go `TestPasswordlessSmoke` (Postgres, rp_id localhost) with subtests: `setting_off` (both routes 403); `passkey_signs_in` (setting on, register with passwordless true, UV and credProps rk true, then options and verify with the userHandle answer a token for that user and fire one login event with Source passkey); `second_factor_key_refused` (a credential registered without passwordless answers 401); `uv_missing_refused` (an assertion without the UV flag answers 401); `banned_refused` (a banned user's passkey answers 401); `state_once` (verify reusing a consumed challenge answers 401). Commit in sm-mfa-plugin: `feat: passwordless passkey login`.
+
+D-09 in sm-sociallogin-plugin. classes/flow.go `signIn`: before FinishLogin call `usercontrollers.CheckLoginGates(ctx, app, userclasses.LoginAttempt{User: user, Source: userclasses.LoginSourceSocial, Request: r})`; an error returns the error (the callback's existing error path; no token); a result with status 200 (or 0) returns `Outcome{Kind: OutcomeLogin, Challenge: res.Body, Action: action, ReturnTo: returnTo}` without calling FinishLogin or IssueToken; any other status returns `FlowError{Key: "two_factor_unavailable"}`. `linkIdentity` stays ungated. classes/codes.go: `CompletionPayload` gains `Challenge map[string]any` with JSON name challenge and omitempty, and the callback stores the outcome's Challenge in it. controllers/complete.go: when the pulled payload has a Challenge, answer 200 with exactly that body (no token cookie handling); register_complete.go likewise for an outcome with a Challenge. Lang en and pl `oauth.two_factor_unavailable` ("Two-factor verification is temporarily unavailable. Try again later." and Polish). go.mod keeps requiring sm-user-plugin v0.1.5 with the go.work replace until Task 5. README (same commit): a "Two-factor sign-in" section: with a two-factor plugin that enables its social-login setting, oauth-complete and oauth-register-complete answer the two_factor_required body instead of a token, the SPA callback must handle it before the setting is turned on, linking is never gated, trusted devices do not apply (no login body). Smoke test social_gate_smoke_test.go `TestSocialGateSmoke` (Postgres, a fake gate added to the published LoginGates, no dependency on sm-mfa-plugin) with subtests: `no_gate_unchanged` (the tracer's sign-in answers token, user, action, return_to); `challenge_body` (a gate returning Status 200 with a two_factor_required body: oauth-complete answers exactly that body, no token key, and a soft-deleted identity owner stays soft-deleted); `refusal_redirects` (Status 401: the callback redirects with ?error= carrying the two_factor_unavailable message); `gate_error_fails_closed` (no completion code with a token is issued); `link_not_gated` (action=link with a link ticket calls the gate zero times). Commit in sm-sociallogin-plugin: `feat: social sign-in through the login gate`.
+
+ go -C ../sm-mfa-plugin vet ./... && go -C ../sm-mfa-plugin test -count=1 -short ./... && go -C ../sm-sociallogin-plugin vet ./... && go -C ../sm-sociallogin-plugin test -count=1 -short ./...
+ Non-zero exit: vet or a short test of either plugin is red.
+ go -C ../sm-mfa-plugin test -count=1 -run '^(TestPasswordlessSmoke|TestWebAuthnSmoke)$' -v . 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 2
+ The count of top-level "--- PASS: Test" lines is not 2: a passkey rule does not hold or a test skipped because Docker was unreachable (rerun through sudo -u golem, else record NOT RUN).
+ go -C ../sm-sociallogin-plugin test -count=1 -run '^(TestSocialGateSmoke|TestTracerSmoke)$' -v . 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 2
+ The count of top-level "--- PASS: Test" lines is not 2: the social gate behaves wrongly, the v0.1.2 tracer regressed, or a test skipped (Docker unreachable: record NOT RUN).
+
+
+ - `grep -c 'ValidatePasskeyLogin' ../sm-mfa-plugin/classes/passwordless.go` prints at least 1 and `grep -c 'VerificationRequired' ../sm-mfa-plugin/classes/passwordless.go` prints at least 1.
+ - `grep -c '"/webauthn/passwordless/options"' ../sm-mfa-plugin/routes.go` prints 1 and `grep -c '"/webauthn/passwordless/verify"' ../sm-mfa-plugin/routes.go` prints 1.
+ - `grep -c 'Passwordless login is not available.' ../sm-mfa-plugin/controllers/passwordless.go` prints at least 1.
+ - `grep -c 'CheckLoginGates(' ../sm-sociallogin-plugin/classes/flow.go` prints 1 and `grep -c 'LoginSourceSocial' ../sm-sociallogin-plugin/classes/flow.go` prints 1.
+ - `grep -c 'json:"challenge,omitempty"' ../sm-sociallogin-plugin/classes/codes.go` prints 1.
+ - `grep -c 'two_factor_unavailable' ../sm-sociallogin-plugin/lang/en/lang.yaml` prints 1 and the same count for lang/pl/lang.yaml is 1.
+ - `grep -c 'two_factor_required' ../sm-sociallogin-plugin/README.md` prints at least 1.
+
+ A user with a passkey signs in without a password where the site allows it, and sites that want it can require the second factor after a social sign-in too.
+
+
+
+ Task 3: The demo site lets a signed-in user turn on an authenticator app with a QR code, keep their recovery codes, turn it off, and answer the login challenge with a TOTP or recovery code
+ ../sm-demo-app/vue-demo-app/app/lib/twoFactor.ts, ../sm-demo-app/vue-demo-app/app/lib/twoFactor.test.ts, ../sm-demo-app/vue-demo-app/app/composables/useDemoAuth.ts, ../sm-demo-app/vue-demo-app/app/composables/useTwoFactor.ts, ../sm-demo-app/vue-demo-app/app/pages/login.vue, ../sm-demo-app/vue-demo-app/app/pages/account/security.vue, ../sm-demo-app/vue-demo-app/app/components/SiteHeader.vue, ../sm-demo-app/vue-demo-app/i18n/locales/en.json, ../sm-demo-app/vue-demo-app/i18n/locales/pl.json
+ ../sm-demo-app/vue-demo-app/app/composables/useDemoAuth.ts, ../sm-demo-app/vue-demo-app/app/pages/login.vue, ../sm-demo-app/vue-demo-app/app/pages/register.vue, ../sm-demo-app/vue-demo-app/app/pages/subscriptions.vue, ../sm-demo-app/vue-demo-app/app/components/SiteHeader.vue, ../sm-demo-app/vue-demo-app/app/utils/authCookie.ts, ../sm-demo-app/vue-demo-app/app/assets/css/site.css, ../sm-demo-app/vue-demo-app/i18n/locales/en.json, ../sm-demo-app/vue-demo-app/i18n/locales/pl.json, ../sm-demo-app/vue-demo-app/vitest.config.ts, ../sm-demo-app/vue-demo-app/app/lib/forms.ts, ../sm-demo-app/vue-demo-app/app/utils/journalLocale.test.ts (test style), /media/nvme/dev/jz/wavepath.org/vue-wavepath-app/src/types/auth.ts (:157-162), 19.6-02-SUMMARY.md (management response bodies, D-19 recovery_codes), 19.6-PATTERNS.md (VDEMO section)
+ Per D-20, in the submodule checkout ../sm-demo-app/vue-demo-app (check `git -C ../sm-demo-app/vue-demo-app status --porcelain` is empty and its branch is master tracking origin first; stop and report otherwise). Never touch ../vue-demo-app.
+
+app/lib/twoFactor.ts (pure, unit-tested): types `TwoFactorChallenge` ({two_factor_required: true, challenge_token, available_methods, expires_at}), `TwoFactorStatus` (the PHP status keys), `TotpSetup` ({secret, qr_code_svg, qr_code_url}); `isTwoFactorChallenge(value)` (true only for an object with two_factor_required === true and a 64-character string challenge_token); `normalizeRecoveryCode(s)` (uppercase, spaces removed, as the server does); `isTotpCode(s)` (exactly six digits after trimming); `challengeExpired(expiresAt, now)` (true for an unparsable or past expires_at); `svgDataUrl(svg)` (`data:image/svg+xml;base64,` plus base64 of the UTF-8 bytes, so the server's SVG is shown through an img and never injected as markup). app/lib/twoFactor.test.ts covers each with positive and negative cases (a token answer is not a challenge; a 63-character token is not; " abcde-23456 " normalizes to "ABCDE-23456"; "12345" and "1234567" are not TOTP codes; the data URL decodes back to the input).
+
+useDemoAuth.ts: `login` returns `{ challenge: TwoFactorChallenge }` when the answer is a challenge and `{}` after storing a token as today (the caller decides); new `verifyChallenge(challengeToken, code)` posts `{challenge_token, method: 'totp', code}` to `/_user/api/v1/2fa/verify` and `recoverChallenge(challengeToken, code)` posts to `/_user/api/v1/2fa/recovery`, both storing the returned token. useTwoFactor.ts: `useTwoFactor()` with `status()`, `setupTotp()`, `confirmTotp(secret, code)` (returns the status and the recovery_codes when present) and `disableTotp()`, each `$fetch` with `Authorization: Bearer ` from the DEMO_TOKEN_COOKIE cookie. login.vue: after a challenge answer, show a second form in the same panel: a code input (inputmode numeric, autocomplete one-time-code, maxlength 6) with a submit, a link-button switching to a recovery-code input, a "start over" link-button back to the password form; an expired challenge (challengeExpired) or a 401 shows the server's error through authErrors and offers start over; success navigates to `next` as today. New pages/account/security.vue: logged-out notice like subscriptions.vue; when TOTP is off, "Set up an authenticator app" calls setupTotp and shows the QR through `` with width and height 200 and alt text, the secret as copyable text for manual entry and a confirm form; after confirm, when recovery_codes came back, show them once in a list with a note to store them and a "Saved them" button that clears them from memory; when TOTP is on, the remaining recovery-code count and a "Turn off" button with a confirm(). SiteHeader.vue: a NuxtLink to /account/security beside "My presentations" when logged in. i18n en and pl: keys `auth.twoFactor.*` (title, prompt, code label, verify, useRecovery, useApp, recoveryLabel, startOver, expired, failed) and `security.*` (title, lead, setup, scan, manual, confirm, enabled, disabled, turnOff, turnOffConfirm, recoveryTitle, recoveryNote, saved, remaining, loginFirst) with natural Polish. Styling uses only the existing classes (panel, form, button, link-button, form__error, notice, muted).
+
+Commit in the vue-demo-app repository (local only until Task 5): `feat: two-factor setup and the login challenge`.
+
+ pnpm --dir ../sm-demo-app/vue-demo-app test && pnpm --dir ../sm-demo-app/vue-demo-app exec vitest run --reporter=verbose app/lib/twoFactor.test.ts 2>&1 | grep -q 'twoFactor.test.ts'
+ Non-zero exit of the vitest suite, or no line naming twoFactor.test.ts in the verbose run (the new tests are missing or were not collected).
+ pnpm --dir ../sm-demo-app/vue-demo-app build
+ Non-zero exit: the Nuxt build fails (type or template errors in the new page, composables or header).
+
+
+ - `grep -c 'v-html' ../sm-demo-app/vue-demo-app/app/pages/account/security.vue` prints 0 and `grep -c 'svgDataUrl' ../sm-demo-app/vue-demo-app/app/pages/account/security.vue` prints at least 1.
+ - `grep -c '2fa/verify' ../sm-demo-app/vue-demo-app/app/composables/useDemoAuth.ts` prints 1 and `grep -c '2fa/recovery' ../sm-demo-app/vue-demo-app/app/composables/useDemoAuth.ts` prints 1.
+ - `grep -c 'totp/setup' ../sm-demo-app/vue-demo-app/app/composables/useTwoFactor.ts` prints 1 and `grep -c 'recovery_codes' ../sm-demo-app/vue-demo-app/app/composables/useTwoFactor.ts` prints at least 1.
+ - `grep -c '/account/security' ../sm-demo-app/vue-demo-app/app/components/SiteHeader.vue` prints at least 1.
+ - `grep -c '"twoFactor"' ../sm-demo-app/vue-demo-app/i18n/locales/en.json` prints 1 and the same count for pl.json is 1.
+ - `R=$(git -C ../sm-demo-app/vue-demo-app remote get-url origin) && test "$R" = "git@git.golem15.com:golem15/vue-demo-app.git"` exits 0 and `S=$(git -C ../vue-demo-app status --porcelain) && test -z "$S"` exits 0 (the stale sibling stays untouched).
+
+ A visitor on the demo can protect their account with an authenticator app, keep their recovery codes, and pass the extra login step.
+
+
+
+ Task 4: Go-ahead for the phase's remote operations in D-17 order (sm-user-plugin push and tag, new sm-mfa-plugin repository and tag, sm-sociallogin-plugin push and tag, vue-demo-app push, then sm-demo-app and sm-bm-app pushes after their commits)
+ 19.6-CONTEXT.md (D-17), 19.6-01-SUMMARY.md, 19.6-02-SUMMARY.md, CLAUDE.md (Commits and concurrency), .planning/notes/core-plugins-own-repos.md
+
+ - The SUMMARY draft records the reply verbatim under a "Task 4 reply" heading, with the tag numbers it names.
+ - Before the reply, `R=$(git -C ../sm-mfa-plugin remote) && test -z "$R"` exits 0 and `T=$(git -C ../fonoteka.go/plugins/golem15/user tag --points-at HEAD) && test -z "$T"` exits 0.
+
+ Which remote operations may this phase perform, and with which tags?
+ Tasks 1-3 and plans 01-02 are committed locally and green. Task 6 mounts sm-mfa-plugin as a git submodule in sm-demo-app and sm-bm-app and bumps sm-user-plugin and sm-sociallogin-plugin pointers, which must reference pushed commits (CLAUDE.md: plugin pushed first, pointer bump in the application as a separate commit). sm-demo-app's TestGoModRequiresTags requires every mounted plugin at a vX.Y.Z tag, so the demo cannot mount mfa without tags; sm-bm-app pins plugins as v0.0.0 with replaces and needs only pushed commits. Proposed tags: sm-user-plugin the next patch after its newest tag on origin (v0.1.6 unless Phase 19.8 tagged one meanwhile; it carries the login gates, events, reset slot and the device table migration, which become one-way for applications that run migrate), sm-sociallogin-plugin v0.1.3, sm-mfa-plugin v0.1.0 (new private repository created with `tea repos create --owner golem15 --name sm-mfa-plugin`). vue-demo-app is pushed without a tag. No framework tag and no changelog post are needed (no framework change). fonoteka.go gets a local commit only, as always. Nothing is deployed in any option.
+
+
+
+
+
+ Reply "release" (optionally with tags), "push-only" or "hold". release and push-only also authorise pushing sm-demo-app and sm-bm-app after Task 6's commits, each only when its unpushed commits are this phase's (or ones the reply names); nothing is ever force-pushed or deployed. Say whether plan 04 may publish patch tags for post-release fixes under this same reply or must ask again.
+
+
+
+ Task 5: Under the Task 4 reply, publish in D-17 order: sm-user-plugin, then sm-mfa-plugin and sm-sociallogin-plugin pinned to it, then vue-demo-app
+ 19.6-03-SUMMARY.md (draft) records the Task 4 reply "release" or "push-only"; on "hold" this task records "remote operations held" and does nothing else.
+ Pushed tags and the device-session and 2FA migrations become what applications pin and run; undoing them needs new releases and migrations (gated by Task 4).
+ ../sm-mfa-plugin/go.mod, ../sm-mfa-plugin/go.sum, ../sm-mfa-plugin/README.md, ../sm-sociallogin-plugin/go.mod, ../sm-sociallogin-plugin/go.sum
+ 19.6-03-SUMMARY.md draft (Task 4 reply), 19.6-01-SUMMARY.md and 19.6-02-SUMMARY.md (this phase's commits per repository), CLAUDE.md (Commits and concurrency), .planning/STATE.md decisions for Phases 19.1 and 19.5 (tea repos create precedent), ../sm-sociallogin-plugin/go.mod
+ Per D-17 and the recorded reply. Before every push: `git fetch origin`, `git status --porcelain` shows no change to this phase's paths, and `git log --oneline origin/master..master` lists only this phase's commits from the SUMMARYs (or ones the reply names); if anything else appears, or origin moved ahead, stop and report (no rebase without the user). Never force-push. Never deploy.
+
+On "release": (1) sm-user-plugin (../fonoteka.go/plugins/golem15/user): `go -C ../fonoteka.go vet ./plugins/golem15/user/...` and the short suite green; `git push origin master`; annotated tag with the reply's version (one paragraph: login gates, login and password-changed events, reset-2FA slot, device authorization); push the tag. (2) sm-mfa-plugin: require that sm-user-plugin tag and the framework version sm-sociallogin-plugin requires (v0.3.1; a newer tag on origin only if the code needs one, checked with `git -C ../summercms.go ls-remote --tags origin`), `go mod tidy`, vet and short tests green, README Dependencies names both versions; commit `chore: require sm-user-plugin `; `tea repos create --owner golem15 --name sm-mfa-plugin` (private unless the reply says otherwise), `git remote add origin git@git.golem15.com:golem15/sm-mfa-plugin.git`, `git push -u origin master`, annotated tag v0.1.0 (or the reply's), push it. (3) sm-sociallogin-plugin: require the same sm-user-plugin tag, `go mod tidy`, vet and short tests green; commit `chore: require sm-user-plugin `; push master; annotated tag (v0.1.3 or the reply's); push it. (4) vue-demo-app (../sm-demo-app/vue-demo-app): push master (no tag).
+
+On "push-only": the same pushes without tags; sm-mfa-plugin and sm-sociallogin-plugin keep their current requires (recorded deviation) and the repository is created and pushed without a tag.
+
+Record in the SUMMARY: each pushed commit range, each tag, the new repository URL.
+
+ go -C ../sm-mfa-plugin vet ./... && go -C ../sm-mfa-plugin test -count=1 -short ./... && git -C ../sm-mfa-plugin ls-remote --exit-code origin refs/heads/master && git -C ../fonoteka.go/plugins/golem15/user 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 a pushed branch does not exist (repository not created or not pushed). On "hold" the command is not run and the SUMMARY says so.
+
+
+ - On release: `git -C ../fonoteka.go/plugins/golem15/user ls-remote --exit-code --tags origin ` exits 0, `git -C ../sm-mfa-plugin ls-remote --exit-code --tags origin v0.1.0` exits 0, `git -C ../sm-sociallogin-plugin ls-remote --exit-code --tags origin ` exits 0, and `grep -c 'git.golem15.com/golem15/sm-user-plugin ' ../sm-mfa-plugin/go.mod` prints 1.
+ - On release and push-only: `L=$(git -C ../fonoteka.go/plugins/golem15/user log --oneline origin/master..master) && test -z "$L"` exits 0 and `L=$(git -C ../sm-demo-app/vue-demo-app log --oneline origin/master..master) && test -z "$L"` exits 0.
+
+ The user plugin, the new two-factor plugin, the social-login plugin and the demo frontend are on git.golem15.com in D-17 order, so applications can pin them.
+
+
+
+ Task 6: The demo and bm applications mount two-factor sign-in and get the device flow, and fonoteka.go moves to the new user plugin with its guard tests updated
+ 19.6-03-SUMMARY.md (draft) records Task 5's pushes for "release" or "push-only"; on "hold" only part (3) below runs.
+ ../sm-demo-app/.gitmodules, ../sm-demo-app/plugins/golem15/mfa, ../sm-demo-app/plugins/golem15/user, ../sm-demo-app/plugins/golem15/sociallogin, ../sm-demo-app/vue-demo-app, ../sm-demo-app/go.mod, ../sm-demo-app/go.sum, ../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/mfa_test.go, ../sm-demo-app/config/golem15.mfa.yaml, ../sm-demo-app/deploy/env.example, ../sm-demo-app/README.md, ../sm-bm-app/.gitmodules, ../sm-bm-app/plugins/golem15/mfa, ../sm-bm-app/plugins/golem15/user, ../sm-bm-app/plugins/golem15/sociallogin, ../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/mfa_test.go, ../sm-bm-app/README.md, ../fonoteka.go/plugins/golem15/user, ../fonoteka.go/plugins/golem15/fonoteka/phase07_coverage_test.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/hidden_marshal_test.go
+ 19.6-03-SUMMARY.md draft (reply, tags), `git -C ../sm-demo-app show --stat d5a98d9` and `git -C ../sm-bm-app show --stat 117ef8f` (mount precedents), ../sm-demo-app/summer.yaml, ../sm-demo-app/go.work, ../sm-demo-app/go.mod, ../sm-demo-app/go_mod_test.go, ../sm-demo-app/.gitmodules, ../sm-demo-app/boot_test.go, ../sm-demo-app/sociallogin_test.go, ../sm-demo-app/deploy/env.example, ../sm-demo-app/README.md, ../sm-demo-app/DEPLOY.md, ../sm-bm-app/summer.yaml, ../sm-bm-app/go.mod, ../sm-bm-app/go.work, ../sm-bm-app/sociallogin_test.go, ../sm-bm-app/README.md, ../fonoteka.go/plugins/golem15/fonoteka/phase07_coverage_test.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/hidden_marshal_test.go, `git -C ../fonoteka.go show --stat 7fc790d`, ../sm-mfa-plugin/README.md (config keys)
+ Per D-17, each application commit separate, plugins already pushed (Task 5). Check `git status --porcelain` of each application before editing; stop and report when another session's edits touch a file below.
+
+(1) sm-demo-app (release only; on push-only record the deviation and skip to (2)): commit A `chore: bump sm-user-plugin to ` (submodule checkout of the tag, go.mod require, go.sum through `go mod tidy`); commit B `chore: bump sm-sociallogin-plugin to ` likewise; commit C `feat: mount golem15.mfa`, as d5a98d9 did for sociallogin: `git -C ../sm-demo-app submodule add -b master git@git.golem15.com:golem15/sm-mfa-plugin.git plugins/golem15/mfa` (or ssu) at tag v0.1.0, go.work `use ./plugins/golem15/mfa`, summer.yaml entry `- id: golem15.mfa` / `module: git.golem15.com/golem15/sm-mfa-plugin` after golem15.sociallogin and before golem15.demo, go.mod require v0.1.0 and tidy, `summer build` (installed from ../summercms.go with `go install ./cmd/summer`) regenerating plugins.gen.go (keep only that change; report anything else it rewrites), boot_test.go `wantPlugins` gains golem15.mfa in summer.yaml order and the routed prefixes gain `/_user/api/v1/2fa/` and `/_user/api/v1/devices`, config/golem15.mfa.yaml with two_factor_mode optional, two_factor_available_methods [totp, email], the social-login toggle off and a comment that the deployed demo serves SPA and API on one host so the WebAuthn RP defaults to the app.url host while a local 127.0.0.1 run cannot use WebAuthn (Pitfall 7), deploy/env.example with commented SUMMER_GOLEM15__MFA__ lines for the mode and the WebAuthn RP override, README plugin row; new mfa_test.go `TestMFAMounted` booting the committed config without a database (as socialServer does) and asserting that POST /_user/api/v1/2fa/verify, POST /_user/api/v1/2fa/webauthn/passwordless/options, GET /_user/api/v1/2fa/status, POST /_user/api/v1/devices/initiate, GET /_user/api/v1/devices/status/{token} and DELETE /_user/api/v1/devices/{id} resolve; commit D `chore: bump vue-demo-app to the two-factor UI` (pointer only). Push sm-demo-app per the reply after the pre-push check.
+
+(2) sm-bm-app (release and push-only): commit A `chore: bump sm-user-plugin to ` (pointer, plus go.mod or go.sum only if they change); commit B `chore: bump sm-sociallogin-plugin to `; commit C `feat: mount golem15.mfa` as 117ef8f did: submodule at plugins/golem15/mfa, go.mod `require git.golem15.com/golem15/sm-mfa-plugin v0.0.0` with `replace git.golem15.com/golem15/sm-mfa-plugin => ./plugins/golem15/mfa`, go.work use, summer.yaml entry after golem15.sociallogin, regenerated plugins.gen.go, README row (API only; the frontend has no 2FA screens, so users cannot enable 2FA there yet and logins are unchanged), mfa_test.go `TestMFAMounted` (boot without a database like TestSocialLoginMounted, the same route assertions). Push sm-bm-app per the reply.
+
+(3) fonoteka.go (always, local commit only, never pushed): one commit `chore: bump sm-user-plugin to ` staging only `plugins/golem15/user` (already at the pushed commit, or the local one on hold) and the test updates the full suite needs: phase07_coverage_test.go removes the device-routes fragment from `banned` and keeps the 2fa fragment (fonoteka still must not resolve the two-factor routes); hidden_marshal_test.go raises `expectedUserModels` by one with a comment naming DeviceAuthSession (7fc790d precedent); any other fonoteka test that inventories user-plugin tables, routes or permissions (parity/schema_diff_test.go Go-only allowances, console/dev_seed_test.go, dev_stack_smoke_test.go) gets the matching additive entry with a comment, only where the full suite shows it is needed. fonoteka.go does not mount golem15.mfa.
+
+ 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 builds, or a short test is red at the bumped user-plugin pointer (for example the guard test still bans /devices or the model count is stale).
+ go -C ../sm-demo-app build ./... && go -C ../sm-demo-app vet . && go -C ../sm-demo-app test -count=1 -short -run '^(TestMFAMounted|TestBootAllPlugins|TestGoModRequiresTags)$' -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: the demo does not build, a route does not resolve, a plugin is missing from wantPlugins, or a require is not a tag. On push-only and hold this command is not run and the SUMMARY says so.
+ 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 '^TestMFAMounted$' -v . 2>&1 | grep -q -- '^--- PASS: TestMFAMounted'
+ Non-zero exit, or no "--- PASS: TestMFAMounted" line: bm does not build or the mfa routes do not resolve. On hold this command is not run and the SUMMARY says so.
+
+
+ - `grep -c '"/devices"' ../fonoteka.go/plugins/golem15/fonoteka/phase07_coverage_test.go` prints 0 and `grep -c '"2fa"' ../fonoteka.go/plugins/golem15/fonoteka/phase07_coverage_test.go` prints 1.
+ - `grep -c 'golem15.mfa' ../fonoteka.go/summer.yaml` prints 0 and `M=$(go -C ../fonoteka.go list -m all) && test "$(printf '%s\n' "$M" | grep -c 'go-webauthn')" -eq 0` exits 0 (D-01).
+ - On release: `grep -c 'id: golem15.mfa' ../sm-demo-app/summer.yaml` prints 1, `grep -c 'git.golem15.com/golem15/sm-mfa-plugin v0.1.0' ../sm-demo-app/go.mod` prints 1, and `grep -c 'golem15.mfa' ../sm-demo-app/plugins.gen.go` prints at least 1.
+ - On release and push-only: `grep -c 'id: golem15.mfa' ../sm-bm-app/summer.yaml` prints 1 and `L=$(git -C ../sm-bm-app log --oneline origin/master..master) && test -z "$L"` exits 0 after the push.
+ - `L=$(git -C ../fonoteka.go log -1 --format=%s -- plugins/golem15/user) && test "${L#chore: bump sm-user-plugin}" != "$L"` exits 0 (the pointer moved in the bump commit).
+
+ Both applications offer two-factor sign-in and device authorization, the demo shows the screens for it, and fonoteka.go builds and passes at the new user plugin without mounting two-factor sign-in.
+
+
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+|----------|-------------|
+| browser authenticator to the WebAuthn routes | attestations and assertions arrive from the client and must be bound to server-side ceremony state |
+| frontend origin to the RP | the RP ID and allowed origins decide which pages may run ceremonies |
+| anonymous client to the passwordless routes | a passkey alone yields a token |
+| social provider callback to signIn | a social identity must not bypass an enabled second factor |
+| demo frontend to the API | server-generated SVG rendered in the browser |
+| this machine to git.golem15.com | tags, pushes and a new repository |
+
+## STRIDE Threat Register
+
+| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
+|-----------|----------|-----------|----------|-------------|-----------------|
+| T-19.6-26 | Spoofing | WebAuthn challenge supplied by the client (Pitfall 6) | high | mitigate | expected challenges held server-side (challenge row code; cooler Pull for registration and passkeys, single use, 120 s); a client challenge is only a lookup key; TestWebAuthnSmoke state reuse |
+| T-19.6-27 | Elevation of privilege | passkey login with a second-factor-only credential (D-14) | high | mitigate | passwordless column set only for requested, UV-verified, credProps-rk credentials; ValidatePasskeyLogin with UV required; handler checks enabled, passwordless and the user handle; setting off by default; TestPasswordlessSmoke; explicit D-14 review in plan 04 |
+| T-19.6-28 | Spoofing | cloned authenticator | medium | mitigate | credential written back after every assertion with sign count and flags; a clone warning refuses the login; BE changes refused by the library (Pitfall 8); TestWebAuthnCeremonySmoke |
+| T-19.6-29 | Spoofing | RP ID or origin misconfiguration (Pitfall 7) | medium | mitigate | explicit rp_id and origins config with app.url and frontend_url defaults; the library checks origin and RP ID hash; README documents IP hosts and split domains |
+| T-19.6-30 | Elevation of privilege | social sign-in bypassing an enabled second factor (D-09) | high | mitigate | gate in signIn before FinishLogin and before any token; challenge body replaces the token in oauth-complete and oauth-register-complete; errors fail closed; link stays ungated because the caller is signed in; TestSocialGateSmoke |
+| T-19.6-31 | Elevation of privilege | passkey login of a banned, suspended or inactive account | high | mitigate | activation check and RejectIfThrottled before the token; one generic failure body; TestPasswordlessSmoke banned_refused |
+| T-19.6-32 | Tampering | remote operations (Task 5 and Task 6 pushes) | medium | mitigate | blocking decision first; fetch and compare the unpushed range to this phase's commits before each push; never force; no deploy; fonoteka.go never pushed |
+| T-19.6-33 | Tampering | demo rendering the server's QR SVG | low | mitigate | rendered through an img data URL, never v-html (acceptance criterion greps for it) |
+| T-19.6-34 | Information disclosure | WebAuthn user handle derived from the numeric user id (A4) | low | accept | PHP-compatible handle keeps imported passkeys mappable; it reveals only an internal id to the user's own authenticator |
+| T-19.6-35 | Denial of service | registration and passkey state in the memory cooler on multi-instance deployments (A11) | low | accept | documented in the README: multi-instance applications need the Redis cooler driver; ceremonies fail closed otherwise |
+| T-19.6-SC | Tampering | Go module installs | high | mitigate | go-webauthn v0.18.2 installed only after plan 01 Task 5's recorded approval; any other version needs the user's approval; `go list -deps` free of the archived YAML module; fonoteka.go's module graph has no go-webauthn |
+
+
+
+- Task 1: TestWebAuthnCeremonySmoke (short), TestWebAuthnSmoke and the plan 02 smokes.
+- Task 2: TestPasswordlessSmoke, TestWebAuthnSmoke; TestSocialGateSmoke and sociallogin's TestTracerSmoke.
+- Task 3: vitest with twoFactor.test.ts and the Nuxt build.
+- Task 4: the reply recorded. Task 5: ls-remote checks. Task 6: fonoteka.go full short suite and build; demo TestMFAMounted, TestBootAllPlugins, TestGoModRequiresTags; bm suite and TestMFAMounted.
+- Feedback sampling (19.6-VALIDATION.md): after each commit, vet and the short suite of the touched repository.
+
+
+
+- SC-1: WebAuthn registration and login challenges work with the standard shapes; passkey login behind its setting.
+- D-09: social sign-in goes through the challenge only when the setting is on.
+- D-17: published in order; demo and bm mount mfa; fonoteka.go green at the new pointer without mfa.
+- D-20: the demo's TOTP setup, recovery codes, disable and login challenge screens.
+
+
+
diff --git a/.planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-04-PLAN.md b/.planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-04-PLAN.md
new file mode 100644
index 00000000..0125493e
--- /dev/null
+++ b/.planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-04-PLAN.md
@@ -0,0 +1,349 @@
+---
+phase: 19.6-two-factor-auth-and-device-flow-in-sm-user-plugin
+plan: 04
+type: execute
+wave: 4
+depends_on: ["19.6-01", "19.6-02", "19.6-03"]
+files_modified:
+ - scripts/check-phase19.6.sh
+ - ../fonoteka.go/plugins/golem15/user/login_gate_test.go
+ - ../fonoteka.go/plugins/golem15/user/password_changed_test.go
+ - ../fonoteka.go/plugins/golem15/user/reset_two_factor_test.go
+ - ../fonoteka.go/plugins/golem15/user/device_auth_test.go
+ - ../fonoteka.go/plugins/golem15/user/classes/device_auth_test.go
+ - ../fonoteka.go/plugins/golem15/user/updates/device_auth_sessions_test.go
+ - ../sm-mfa-plugin/gate_test.go
+ - ../sm-mfa-plugin/challenge_test.go
+ - ../sm-mfa-plugin/totp_test.go
+ - ../sm-mfa-plugin/recovery_test.go
+ - ../sm-mfa-plugin/email_test.go
+ - ../sm-mfa-plugin/trusted_test.go
+ - ../sm-mfa-plugin/settings_test.go
+ - ../sm-mfa-plugin/status_test.go
+ - ../sm-mfa-plugin/admin_test.go
+ - ../sm-mfa-plugin/tokens_at_rest_test.go
+ - ../sm-mfa-plugin/migrations_test.go
+ - ../sm-mfa-plugin/webauthn_test.go
+ - ../sm-mfa-plugin/passwordless_test.go
+ - ../sm-mfa-plugin/routes_test.go
+ - ../sm-mfa-plugin/php_bodies_test.go
+ - ../sm-mfa-plugin/login_paths_test.go
+ - ../sm-mfa-plugin/php_method_map_test.go
+ - ../sm-mfa-plugin/php_port_test.go
+ - ../sm-mfa-plugin/testdata/PHP_METHOD_MAP.md
+ - ../sm-mfa-plugin/testdata/php_methods.txt
+ - ../sm-mfa-plugin/README.md
+ - ../sm-sociallogin-plugin/social_gate_test.go
+ - .planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-SECURITY-REVIEW.md
+ - .planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-VALIDATION.md
+ - ../sm-mfa-plugin/go.mod
+ - ../sm-mfa-plugin/go.sum
+ - ../sm-sociallogin-plugin/go.mod
+ - ../sm-sociallogin-plugin/go.sum
+ - ../sm-demo-app/plugins/golem15/mfa
+ - ../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/mfa
+ - ../sm-bm-app/plugins/golem15/user
+ - ../sm-bm-app/plugins/golem15/sociallogin
+ - ../fonoteka.go/plugins/golem15/user
+ - .planning/todos/pending/mfa-post-release-fixes.md
+autonomous: false
+requirements: [SC-4, D-02, D-05, D-06, D-07, D-08, D-09, D-10, D-11, D-12, D-12a, D-12b, D-12c, D-13, D-14, D-15, D-16, D-17, D-18, D-19]
+estimate:
+ tokens: 330000
+ raw_tokens: 330000
+ tasks: 6
+ confidence: low
+must_haves:
+ truths:
+ - "SC-4: the phase's new code has unit tests delivered in this, the last plan: sm-user-plugin's gate, events, reset slot and device flow; every decision of sm-mfa-plugin in the 19.6-RESEARCH.md decision-to-test map; sociallogin's gate; on Postgres where a database is involved."
+ - "D-02, D-18 (A10): table tests pin the gate chain (nil registry, order, first result wins, errors fail closed and stop the chain) and Login's order (rehash before the gate, restore and reactivate mail after it, no event on a challenge)."
+ - "D-11, D-12a, D-12b, D-12c, D-06: table tests cover initiate validation and cleanup, status for every state with the flat expired body and used never expiring, authorize by short code (case, spaces, missing hyphen, 7 and 9 characters, excluded letters) and by token with PHP's 400, 401 and 422 bodies, concurrent authorizes and polls each yielding one winner, the deleted-user hand-over, list order and is_current, revoke of foreign ids, revoke after one and two refreshes, and token columns holding SHA-256 hex."
+ - "D-05, D-06, D-07, D-10, D-12, D-13, D-15, D-16, D-18, D-19: sm-mfa-plugin tests pin every PHP error and success body verbatim, the challenge lifecycle (TTL, five attempts, one completion under 20 parallel verifies), TOTP window and replay with an injected clock, the QR serializer, recovery format and single use, the email gating, send cap and expiry_minutes, the failure-cap formula and window, trusted devices, every revocation source, the settings overlay and allow-lists, the status shape with the Carbon date form, the admin reset, tokens and secrets at rest, and the six migrations up and down with PHP's column names."
+ - "D-08, D-14: WebAuthn tests pin the standard option and response shapes (attestation none, timeout 60000, user verification preferred for second factors, required for passkeys), server-side state consumed once, BE and clone refusals, and every passkey rule; 19.6-SECURITY-REVIEW.md has an explicit D-14 section mapping each passkey risk to a passing test."
+ - "D-09, invariant: TestEveryLoginPathIsGated drives password and social (toggle on) sign-ins of a TOTP user to a challenge with no token, and proves passkey and device-poll tokens arise only through their own proofs; TestSocialGate covers the sociallogin side, link included."
+ - "PHP test map: every method of the PHP plugin's tests/security/AuthenticationTest.php appears in sm-mfa-plugin/testdata/PHP_METHOD_MAP.md with its Go test or not-applicable with a reason; TestPHPMethodMap fails on a missing, unknown or extra method and on a named Go test that does not exist; test_user_005 maps to TestPHPUser005 (typed mode setting)."
+ - "Security review: 19.6-SECURITY-REVIEW.md maps every T-19.6 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.6.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-mfa-plugin, sm-sociallogin-plugin, sm-demo-app (Go and vue-demo-app vitest), sm-bm-app and fonoteka.go, and prints NOT RUN docker without a reachable daemon unless PHASE196_REQUIRE_DOCKER=1 makes that a failure."
+ - "D-17 after the release: every production fix this plan commits after plan 03's release (a change outside *_test.go, testdata/ and README.md in sm-user-plugin, sm-mfa-plugin or sm-sociallogin-plugin) is contained in every pin that consumes it, through patch tags and separate pointer-bump commits under the Task 5 go-ahead; on hold, each unreleased fix is a recorded deviation with a todo and a g15office task."
+ artifacts:
+ - path: "scripts/check-phase19.6.sh"
+ provides: "fail-closed phase gate across seven repositories"
+ contains: "PHASE196_REQUIRE_DOCKER"
+ - path: "../sm-mfa-plugin/login_paths_test.go"
+ provides: "TestEveryLoginPathIsGated (assumption-delta invariant)"
+ contains: "TestEveryLoginPathIsGated"
+ - path: "../sm-mfa-plugin/testdata/PHP_METHOD_MAP.md"
+ provides: "case-by-case map of the PHP AuthenticationTest methods"
+ contains: "test_user_005_twofactor_type_confusion"
+ - path: ".planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-SECURITY-REVIEW.md"
+ provides: "threat-to-test map T-19.6-01..T-19.6-38 and the D-14 review"
+ contains: "D-14"
+ key_links:
+ - from: "scripts/check-phase19.6.sh"
+ to: "../sm-mfa-plugin/php_method_map_test.go"
+ via: "the mfa stage requires TestPHPMethodMap by name"
+ pattern: "TestPHPMethodMap"
+ - from: ".planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-VALIDATION.md"
+ to: "scripts/check-phase19.6.sh"
+ via: "the per-task map names the gate stage and test for each decision"
+ pattern: "check-phase19.6.sh"
+ - from: "../sm-mfa-plugin"
+ to: "../sm-demo-app/plugins/golem15/mfa"
+ via: "a post-release fix reaches the applications through Task 6's patch tag and pointer bump, checked 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 contain a real secret, never call a real authenticator or mail server and never log codes or tokens; WebAuthn runs through the virtual authenticator, mail through the capturing mailer."
+ 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-only commits are pushed only as plan 03's Task 4 reply authorised; tags and pointer bumps happen only in Task 6 under the Task 5 reply (or plan 03's reply when it pre-authorised patch releases); nothing is force-pushed or deployed; fonoteka.go is never pushed."
+ status: unresolved
+ verification: judgment
+---
+
+## Phase Goal
+
+ROADMAP Goal (not in user-story format; no story invented, see plan 01): `sm-user-plugin` has the PHP user plugin's second factors and device login. ROADMAP SC-4: the phase gets a security review and the new code has unit tests, delivered in the phase's last plan (also the project rule "unit tests are the last plan of every phase").
+
+This plan's slice (plan 4 of 4, wave 4, "Unit tests"): one fail-closed gate first (the tracer), then unit tests for sm-user-plugin, then sm-mfa-plugin's and sociallogin's decision-to-test map with the login-path invariant, then the PHP test map, the security review with the explicit D-14 section, the whole gate green and VALIDATION.md completed. If a production fix landed after plan 03's release, a gated patch release carries it to the applications (Tasks 5 and 6).
+
+## 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-20.
+
+## Decisions taken in this plan
+
+- Test names (one top-level test per decision area with table subtests): sm-user-plugin `TestLoginGates` (short), `TestLoginGateOrder`, `TestPasswordChangedSources`, `TestResetTwoFactorAction`, `TestDeviceCodes` (short, classes package), `TestDeviceAuthURL` (short), `TestDeviceDisplayName` (short), `TestDeviceInitiate`, `TestDeviceStatus`, `TestDeviceAuthorize`, `TestDeviceHandOverConcurrent`, `TestDeviceListAndRevoke`, `TestDeviceRevokeAfterRefresh`, `TestDeviceTokensAtRest`, `TestDeviceAuthSessionsMigration` (updates package); sm-mfa-plugin `TestTOTP` (short), `TestQRSVG` (short), `TestRecoveryCodeFormat` (short), `TestMaskEmail` (short), `TestEmailExpiryMinutes` (short), `TestWebAuthnShapes` (short), `TestRoutesAndBuckets` (short), `TestPHPMethodMap` (short), `TestPHPUser005` (short), `TestGate`, `TestChallengeLifecycle`, `TestRecovery`, `TestEmailCode`, `TestFailureCap`, `TestTrustedDevice`, `TestRevocation`, `TestSettings`, `TestStatusShape`, `TestAdminReset`, `TestTokensAtRest`, `TestMigrations`, `TestWebAuthn`, `TestPasswordless`, `TestPHPErrorBodies`, `TestEveryLoginPathIsGated`; sm-sociallogin-plugin `TestSocialGate`. The smoke tests of plans 01-03 stay and are required by the gate too.
+- The gate's user-plugin base for the additive check is the commit plan 01 started from (19.6-01-SUMMARY.md; `35cf166` when nothing moved), overridable with PHASE196_USER_BASE. The migration check allows exactly one added non-test file under updates/ (`202610210002_create_device_auth_sessions.go`) and no modified or removed one.
+- The PHP map covers the one PHP test file with second-factor content, tests/security/AuthenticationTest.php, method by method (RESEARCH: PHP has almost no tests here); 2FA and device cases map to Go tests, the others to not-applicable with the phase or reason that owns them.
+- Post-release fixes (D-17): plan 03 released the plugins and pinned the applications. A defect fix in Tasks 2-4 reaches them only through a new release; Task 4 lists such fixes against every pin; Task 5 asks for the go-ahead unless plan 03's Task 4 reply already authorised patch releases for this plan (then Task 5 records that and continues); Task 6 releases in D-17 order. Test-only and README-only commits need no release.
+- Context budget and hand-offs (estimate 330k across 6 tasks; the four-plan decision fixes the plan count): the first agent runs Tasks 1-2 and writes `19.6-04-SUMMARY.md` as a draft (`Status: draft (hand-off after Task 2)`) with commits per repository, test names, gate stages and NOT RUN notes; a fresh agent runs Tasks 3-4 and ends with the `## Post-release fixes` table; when the table is empty it writes "nothing to propagate" under `Task 5 reply`, records Tasks 5 and 6 as not needed and finalizes the SUMMARY, otherwise it stops at Task 5 (or continues when plan 03's reply pre-authorised); a continuation agent runs Task 6.
+
+## Artifacts this phase produces (this plan)
+
+- `scripts/check-phase19.6.sh` with modes `--go`, `--userplugin`, `--mfa`, `--sociallogin`, `--demo`, `--demo-web`, `--bm`, `--fonoteka`, `--docker`, `--docs`, `--self-test`, `--all`; environment `PHASE196_ROOT`, `USER_PLUGIN`, `MFA_PLUGIN`, `SOCIALLOGIN_PLUGIN`, `DEMO_APP`, `DEMO_WEB`, `BM_APP`, `FONOTEKA`, `PHASE196_USER_BASE`, `PHASE196_REQUIRE_DOCKER`.
+- Unit tests named in Decisions; PHP map files `../sm-mfa-plugin/testdata/PHP_METHOD_MAP.md` and `testdata/php_methods.txt`; README Testing sections listing them.
+- `.planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-SECURITY-REVIEW.md`; the completed `19.6-VALIDATION.md` (status validated, nyquist_compliant true, wave_0_complete true).
+- When needed: patch tags of the touched plugins, pointer-bump commits, `.planning/todos/pending/mfa-post-release-fixes.md`.
+
+
+Deliver the phase's unit tests last: a fail-closed gate over every touched repository, decision-by-decision tests for sm-user-plugin, sm-mfa-plugin and the social-login gate, the login-path invariant, the PHP test map, the security review with the explicit passkey section, and the release of any post-release fix.
+
+Purpose: SC-4 and every decision's test row in RESEARCH's Validation Architecture; the D-14 security review; D-17 for fixes.
+Output: test commits in sm-user-plugin, sm-mfa-plugin and sm-sociallogin-plugin, the gate script in summercms.go, the security review and validation docs, and (only if needed) a gated patch release.
+
+
+
+@~/.claude/msd-core/workflows/execute-plan.md
+@~/.claude/msd-core/templates/summary.md
+
+
+
+@.planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-CONTEXT.md
+@.planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-RESEARCH.md
+@.planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-VALIDATION.md
+@.planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-01-SUMMARY.md
+@.planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-02-SUMMARY.md
+@.planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-03-SUMMARY.md
+
+Repos and concurrency: tests are added in sm-user-plugin (../fonoteka.go/plugins/golem15/user), sm-mfa-plugin and sm-sociallogin-plugin; the gate script and the planning docs in summercms.go. A defect a test exposes is fixed in the owning repository in its own commit with its README change and listed in the SUMMARY's `## Post-release fixes` table. No co-author tags; stage by explicit path; test and production changes in separate commits. Keep vet and the short suites green at every commit. Docker-backed tests skip under -short; run them through `sudo -u golem` if the socket is denied; when containers are unreachable (Phase 19.8 saw this on this host) the docker stage reports NOT RUN and the SUMMARY says so.
+
+
+- scripts/check-phase19.5.sh (whole file: header, `set -euo pipefail`, the go test -json detector, `--self-test`, docker NOT RUN rule, `--all`) and scripts/check-phase19.4.sh: the structure to copy.
+- 19.6-01/02/03 SUMMARYs: final identifiers, smoke test names, migration IDs, tags, the base commit, the Task 4 reply of plan 03.
+- sm-user-plugin test helpers: session_test.go (`sessionApp`, `insertUser`, `loginToken`, `bearer`), admin_harness_test.go (`newAdminEnv`, `admin`, `call`, `expect`, `mails`), updates/postgres_test.go (migration harness, `dedicatedDB`), the plan 01 smoke tests.
+- sm-mfa-plugin test helpers: harness_test.go (`bootMFA`, `freshDB`), webauthn_authenticator_test.go (`virtualAuthenticator`), the plan 02-03 smoke tests; pquerna `totp.GenerateCodeCustom` for codes at injected times.
+- sm-sociallogin-plugin: harness_test.go, fakeprovider_test.go, social_gate_smoke_test.go, testdata/PHP_METHOD_MAP.md and php_method_map_test.go (the map-test pattern to copy).
+- PHP: /media/nvme/dev/jz/wavepath.org/plugins/golem15/user/tests/security/AuthenticationTest.php (USER-005 at :132-190); RESEARCH Q4 for every verbatim body.
+- RESEARCH Validation Architecture (decision-to-test map, Wave 0 gaps), Security Domain (ASVS categories, known threat patterns), Assumptions A1-A13.
+
+
+
+
+
+
+ Task 1: Tracer: scripts/check-phase19.6.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.6.sh
+ scripts/check-phase19.5.sh (whole file), scripts/check-phase19.4.sh, 19.6-01-SUMMARY.md, 19.6-02-SUMMARY.md, 19.6-03-SUMMARY.md (smoke test names, base commit, tags), 19.6-VALIDATION.md
+ Create scripts/check-phase19.6.sh from check-phase19.5.sh's structure (header comment listing every stage, `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, a subtest pass never counting for its parent, `--self-test` with planted output, `--all`). Defaults are the sibling checkouts: PHASE196_ROOT (this repository), USER_PLUGIN (../fonoteka.go/plugins/golem15/user, tested through the fonoteka.go workspace), MFA_PLUGIN (../sm-mfa-plugin), SOCIALLOGIN_PLUGIN (../sm-sociallogin-plugin), DEMO_APP (../sm-demo-app), DEMO_WEB (../sm-demo-app/vue-demo-app), BM_APP (../sm-bm-app), FONOTEKA (../fonoteka.go), PHASE196_USER_BASE (the base from 19.6-01-SUMMARY.md). Stages:
+- `--go`: go vet ./... and go test -short ./... here (the framework is unchanged by this phase; the stage proves it stays green and that the framework go.mod requires none of the three second-factor libraries).
+- `--userplugin`: vet and short tests of the user plugin through the fonoteka.go workspace with the Docker-free names (TestLoginGatesSmoke, TestDeviceCodesSmoke now; Task 2 adds its names); the additive check (no line declaring an exported func, method, type or const removed from a non-test .go file since the base); the migration check (the only non-test change under updates/ is the added device-session migration).
+- `--mfa`: vet and short tests in sm-mfa-plugin with TestQRSVGSmoke and TestWebAuthnCeremonySmoke by name; `go list -deps .` free of the archived YAML v3 module; go.mod pins pquerna/otp v1.5.0, boombuler/barcode v1.1.0 and go-webauthn/webauthn at the version the 19.6-03 SUMMARY records.
+- `--sociallogin`: vet and short tests in the plugin plus its Phase 19.5 rules (no gorilla/sessions, no goth import outside classes/providers_goth.go).
+- `--demo`: go build ./..., go vet ., go test -short . with TestBootAllPlugins, TestGoModRequiresTags and TestMFAMounted by name (skipped with a NOT MOUNTED note when the demo's summer.yaml has no golem15.mfa because plan 03 ran push-only or hold).
+- `--demo-web`: `pnpm --dir "$DEMO_WEB" test` must pass and report app/lib/twoFactor.test.ts.
+- `--bm`: go build ./..., go vet ., go test -short . with TestSocialLoginMounted and TestMFAMounted by name.
+- `--fonoteka`: go vet ./... and go test -short ./... of the workspace; summer.yaml does not mount golem15.mfa; `go list -m all` lists no go-webauthn, pquerna or boombuler module (D-01).
+- `--docker` (Docker host only, "needs a reachable Docker daemon" otherwise): the Postgres smoke tests of plans 01-03 by name without -short (user plugin: TestLoginGateSmoke, TestPasswordChangedEventSmoke, TestResetTwoFactorSlotSmoke, TestDeviceFlowSmoke, TestDeviceRevokeSmoke; mfa: TestTracerSmoke, TestChallengeSmoke, TestTrustedSettingsSmoke, TestWebAuthnSmoke, TestPasswordlessSmoke; sociallogin: TestSocialGateSmoke).
+- `--docs`: the decision note names the three pins; STACK.md has the three rows; TestDocsTree and `go run ./cmd/summer docs:build --check`; no README of sm-mfa-plugin or sm-user-plugin and no file under modules/ or docs/ names a consuming application (case-insensitive grep for plytarium, płytarium, fonoteka, wavepath, horoskopia, queststream and bm.jakubzych outside _test.go files and .planning).
+- `--all`: self-test, go, userplugin, mfa, sociallogin, demo, demo-web, bm, fonoteka, docs, then docker when a daemon answers (NOT RUN docker otherwise; a failure with PHASE196_REQUIRE_DOCKER=1).
+Each later task 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, a vitest run without the required file) and the docker NOT RUN rule on a faked PATH without docker. Commit in summercms.go: `test(19.6): fail-closed phase gate`.
+
+ bash -n scripts/check-phase19.6.sh && scripts/check-phase19.6.sh --self-test && scripts/check-phase19.6.sh --go && scripts/check-phase19.6.sh --userplugin && scripts/check-phase19.6.sh --mfa && scripts/check-phase19.6.sh --sociallogin
+ 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 modified user-plugin migration, an unexpected library version or the archived YAML module in the mfa dependency graph.
+
+
+ - `grep -c 'PHASE196_REQUIRE_DOCKER' scripts/check-phase19.6.sh` prints at least 2.
+ - `grep -cE -- '^\s*--(go|userplugin|mfa|sociallogin|demo|demo-web|bm|fonoteka|docker|docs|self-test|all)\)' scripts/check-phase19.6.sh` prints at least 12.
+ - `grep -c 'TestWebAuthnCeremonySmoke' scripts/check-phase19.6.sh` prints at least 1 and `grep -c '202610210002_create_device_auth_sessions' scripts/check-phase19.6.sh` prints at least 1.
+ - `test -x scripts/check-phase19.6.sh` exits 0.
+
+ One command shows, for every repository the phase touched, whether its suite and the phase's named tests pass, and a skipped or missing test cannot pass it.
+
+
+
+ Task 2: Unit tests for sm-user-plugin's additions (gate chain and Login order, password-changed sources, reset action, the whole device flow and its migration), wired into the gate
+ ../fonoteka.go/plugins/golem15/user/login_gate_test.go, ../fonoteka.go/plugins/golem15/user/password_changed_test.go, ../fonoteka.go/plugins/golem15/user/reset_two_factor_test.go, ../fonoteka.go/plugins/golem15/user/device_auth_test.go, ../fonoteka.go/plugins/golem15/user/classes/device_auth_test.go, ../fonoteka.go/plugins/golem15/user/updates/device_auth_sessions_test.go, ../fonoteka.go/plugins/golem15/user/README.md, scripts/check-phase19.6.sh
+ 19.6-01-SUMMARY.md, ../fonoteka.go/plugins/golem15/user/classes/login_gate.go, ../fonoteka.go/plugins/golem15/user/controllers/api_controller.go (Login, Refresh), ../fonoteka.go/plugins/golem15/user/controllers/devices.go, ../fonoteka.go/plugins/golem15/user/classes/device_auth.go, ../fonoteka.go/plugins/golem15/user/controllers/users_admin_controller.go, the plan 01 smoke tests, ../fonoteka.go/plugins/golem15/user/session_test.go, ../fonoteka.go/plugins/golem15/user/admin_harness_test.go, ../fonoteka.go/plugins/golem15/user/updates/postgres_test.go, ../fonoteka.go/plugins/golem15/user/updates/api_tokens_test.go (migration test style), 19.6-RESEARCH.md (Q4 device flow, Q7, Pitfalls 3, 13-17, 21)
+ Tests only (fix defects in separate commits). `TestLoginGates` (short): nil registry, empty, order, first result wins, error stops the chain, concurrent Add and Check under `go test -race` clean. `TestLoginGateOrder` (Postgres): the rehash happens for a challenged user (stored hash changes when bcrypt_cost rises, no PasswordChangedEvent); a challenged soft-deleted user stays soft-deleted with no mail; an allowed one is restored with one reactivate mail and one LoginEvent; a refusal result's status and body pass through; refresh never calls a gate (a counting gate stays at 0 across /refresh). `TestPasswordChangedSources` (Postgres): reset, change and admin set each fire once with their source inside a transaction (the listener sees the new hash through ev.Tx), rehash and register fire none, a listener error rolls back each of the three writes. `TestResetTwoFactorAction` (admin harness): not offered without a service; offered and Danger with one; HasTwoFactor false hides it; the permission is required; Run without a transaction errors; Run's error rolls back. `TestDeviceCodes` (classes package, short): token length, alphabet and uniqueness over 10,000 draws, short-code format and distribution (every alphabet character appears), normalization table. `TestDeviceAuthURL` and `TestDeviceDisplayName` (short tables, including the PHP accessor order iPhone before Mac). `TestDeviceInitiate` (device_name over 255 characters 422; the cleanup deletes only stale pending and expired rows and at most 500; short-code collision retry with a pre-inserted code). `TestDeviceStatus` (unknown 404 body; pending; expired pending and expired confirmed give the flat body and set status expired; used and revoked past expires_at keep their status, Pitfall 13). `TestDeviceAuthorize` (short code variants and refusals with PHP bodies; token path; a session bound to another user refused; the same caller re-authorizing answers success; 10 concurrent authorizes by two users bind exactly one; missing, invalid and blacklisted bearer 401 with {"error":"Unauthorized"}). `TestDeviceHandOverConcurrent` (50 concurrent polls give one token; the deleted-user hand-over marks revoked and answers status revoked; LoginEvent Source device fired once). `TestDeviceListAndRevoke` (order, is_current, device_name, foreign id 404, non-numeric id unmatched, idempotent revoke, empty list is []). `TestDeviceRevokeAfterRefresh` (one and two refreshes before revoke; the blacklist entry outlives the refresh window; a refresh failure of the device token leaves the row unchanged). `TestDeviceTokensAtRest` (the token column equals SHA-256 hex and never the plaintext; no plaintext in any column). `TestDeviceAuthSessionsMigration` (updates package, dedicated database: up creates PHP's column names plus jwt_jti, the unique token and short_code, the CHECK refuses an unknown status, the FK cascades on user delete, rollback drops the table, re-up works). Append every name to the userplugin and docker stages of the gate; README Testing lists them. Commits in sm-user-plugin: `test: login gates, password-changed sources and the reset action` and `test: device authorization flow and its migration`; gate update in summercms.go: `test(19.6): gate requires the user-plugin unit tests`.
+
+ scripts/check-phase19.6.sh --userplugin && go -C ../fonoteka.go test -count=1 -short -race -run '^(TestLoginGates|TestDeviceAuthURL|TestDeviceDisplayName)$' -v ./plugins/golem15/user 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 3
+ Non-zero exit from the userplugin stage, or the count of top-level "--- PASS: Test" lines is not 3 (a short table test failed, raced or is missing).
+ scripts/check-phase19.6.sh --docker
+ Non-zero exit: a required Postgres test failed or skipped; "needs a reachable Docker daemon" means NOT RUN, recorded in the SUMMARY (rerun through sudo -u golem first).
+
+
+ - `grep -c '^func TestDeviceRevokeAfterRefresh' ../fonoteka.go/plugins/golem15/user/device_auth_test.go` prints 1 and `grep -c '^func TestDeviceHandOverConcurrent' ../fonoteka.go/plugins/golem15/user/device_auth_test.go` prints 1.
+ - `grep -c '^func TestDeviceAuthSessionsMigration' ../fonoteka.go/plugins/golem15/user/updates/device_auth_sessions_test.go` prints 1.
+ - `grep -c 'TestDeviceRevokeAfterRefresh' scripts/check-phase19.6.sh` prints at least 1 and `grep -c 'TestLoginGateOrder' scripts/check-phase19.6.sh` prints at least 1.
+ - `grep -c 'TestDeviceRevokeAfterRefresh' ../fonoteka.go/plugins/golem15/user/README.md` prints at least 1.
+
+ Every user-plugin seam and every device-flow state and refusal is pinned by a named test the gate requires.
+
+
+
+ Task 3: Unit tests for sm-mfa-plugin and the social-login gate, one per decision in the validation map, plus the invariant that every first-factor login path is gated, wired into the gate
+ ../sm-mfa-plugin/gate_test.go, ../sm-mfa-plugin/challenge_test.go, ../sm-mfa-plugin/totp_test.go, ../sm-mfa-plugin/recovery_test.go, ../sm-mfa-plugin/email_test.go, ../sm-mfa-plugin/trusted_test.go, ../sm-mfa-plugin/settings_test.go, ../sm-mfa-plugin/status_test.go, ../sm-mfa-plugin/admin_test.go, ../sm-mfa-plugin/tokens_at_rest_test.go, ../sm-mfa-plugin/migrations_test.go, ../sm-mfa-plugin/webauthn_test.go, ../sm-mfa-plugin/passwordless_test.go, ../sm-mfa-plugin/routes_test.go, ../sm-mfa-plugin/php_bodies_test.go, ../sm-mfa-plugin/login_paths_test.go, ../sm-mfa-plugin/README.md, ../sm-sociallogin-plugin/social_gate_test.go, scripts/check-phase19.6.sh
+ 19.6-02-SUMMARY.md, 19.6-03-SUMMARY.md, every classes/ and controllers/ file of ../sm-mfa-plugin, ../sm-mfa-plugin/harness_test.go, ../sm-mfa-plugin/webauthn_authenticator_test.go, the plan 02-03 smoke tests, ../sm-sociallogin-plugin/classes/flow.go, ../sm-sociallogin-plugin/social_gate_smoke_test.go, 19.6-RESEARCH.md (Q4 every body and table, Validation Architecture decision-to-test map, Pitfalls 1-12, Assumptions A2, A5-A9)
+ Tests only (fix defects in separate commits, listed for Task 4's table). Short tests: `TestTOTP` (injected times at step boundaries: counters at-1, at, at+1 accepted, at+2 and at-2 refused, replay at or below last_counter refused, the URL's issuer, account, algorithm, digits and period), `TestQRSVG` (several lengths and levels round-trip to the matrix; size attributes), `TestRecoveryCodeFormat` (10 codes, regex, alphabet only, no duplicates across 1,000 sets' codes within a set, normalization and the 32-character cap), `TestMaskEmail` (PHP table including one- and two-character local parts and plus addresses), `TestEmailExpiryMinutes` (A9: min of TTL and remaining time, rounded up, never below 1), `TestWebAuthnShapes` (D-08: the marshalled options have publicKey with a base64url challenge, attestation none, timeout 60000, userVerification preferred for second factors and required with residentKey required for passkeys, and the user id is the 8-byte big-endian handle), `TestRoutesAndBuckets` (boot without a database: every challenge and passwordless route carries the 2fa-verify bucket, every management route jwt.auth and throttle:user-api, each {id} route has the digit constraint; the 2fa-verify bucket is 10 per minute keyed by client IP).
+
+Postgres tests: `TestGate` (sources password and social with the toggle on and off; passkey, two_factor and device never challenge; mode disabled exempts even users with methods and nothing else does, D-13; no enabled method passes; a user with only a webauthn row and no credential is challenged with available_methods [] as PHP; available_methods not filtered by the global list; the cap refusal); `TestChallengeLifecycle` (TTL from the setting; five attempts then refusal; 20 parallel verifies give one token; completed and expired challenges refused; creation keeps rows inside the cap window and deletes older expired ones); `TestRecovery` (single use under 10 concurrent attempts; regenerate replaces in one transaction; count route requires the password; D-19 recovery_codes only when a set was created, for totp/confirm, email/enable and webauthn/register); `TestEmailCode` (A8 gating for send and verify; three sends then refusal; each send replaces the code; template name per locale and the vars in both cases; the code is never in a log line, using a capturing slog handler); `TestFailureCap` (the formula over completed and uncompleted rows, the window boundary, refusal at login and on every verify endpoint, recovery after the window); `TestTrustedDevice` (D-10 issue, skip, expiry, other user, disabled setting, last_used_at and ip update, social never skipped, A7); `TestRevocation` (D-07: change, reset and admin set delete trusted devices; rehash does not; last method disable deletes devices and codes; removing the last WebAuthn credential does the same); `TestSettings` (D-12 overlay per key, NULL falls back to config, immediate effect, Rules and BeforeValidate allow-lists, admin JSON of the settings screen); `TestStatusShape` (PHP keys exactly; webauthn_credentials and trusted_devices item keys; the Carbon date form for trusted devices, A2; +00:00 for credentials); `TestAdminReset` (D-15 through the user plugin's record action end to end in the admin harness or through the published service: methods disabled with secrets cleared, credentials, codes and devices deleted, one disabled event, login then answers a token); `TestTokensAtRest` (D-06: challenge and trusted tokens SHA-256 hex, TOTP secret ciphertext that decrypts to the secret, recovery and email codes bcrypt); `TestMigrations` (D-16: the six migrations up, PHP column names and the added columns, indexes and FKs, rollback in reverse, re-up); `TestWebAuthn` (register and verify, state consumed once, wrong origin refused, BE change refused, clone refused, credential_data written back, delete scoped to the owner); `TestPasswordless` (D-14 table: setting off 403; second-factor credential refused; UV missing refused; disabled credential refused; userHandle of another user refused; banned, suspended and inactive refused with the generic body; soft-deleted owner restored and mailed; login event Source passkey; no challenge row created); `TestPHPErrorBodies` (every PHP status and body from RESEARCH Q4's two tables, byte for byte after trimming a trailing newline, including the 422 first-message form); `TestEveryLoginPathIsGated` (the assumption-delta invariant: for a TOTP user, password login and social sign-in with the toggle on answer two_factor_required and no token, social with the toggle off and every non-first-factor source answer tokens only through their own proof: passwordless only with a passkey assertion, the device poll only after an authorize with a bearer JWT; registering a new account and activate-by-code mint without a gate because the account cannot have 2FA yet). sociallogin `TestSocialGate` (refusal, error, challenge payload through oauth-complete and oauth-register-complete, link never gated, token cookie mode not applied to a challenge). Append every name to the mfa, sociallogin and docker stages; README Testing sections list them. Commits: in sm-mfa-plugin `test: second-factor decisions, PHP bodies and the login-path invariant` (split by area if a commit grows past one logical change); in sm-sociallogin-plugin `test: social sign-in gate`; in summercms.go `test(19.6): gate requires the mfa and social gate tests`.
+
+ scripts/check-phase19.6.sh --mfa && scripts/check-phase19.6.sh --sociallogin && go -C ../sm-mfa-plugin test -count=1 -short -run '^(TestTOTP|TestQRSVG|TestRecoveryCodeFormat|TestMaskEmail|TestEmailExpiryMinutes|TestWebAuthnShapes|TestRoutesAndBuckets)$' -v . 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 7
+ Non-zero exit from a stage, or the count of top-level "--- PASS: Test" lines is not 7 (a short decision test failed or is missing).
+ scripts/check-phase19.6.sh --docker
+ Non-zero exit: a required Postgres test failed or skipped; "needs a reachable Docker daemon" means NOT RUN, recorded in the SUMMARY (rerun through sudo -u golem first).
+
+
+ - `grep -c '^func TestEveryLoginPathIsGated' ../sm-mfa-plugin/login_paths_test.go` prints 1 and `grep -c '^func TestPHPErrorBodies' ../sm-mfa-plugin/php_bodies_test.go` prints 1.
+ - `grep -c '^func TestPasswordless' ../sm-mfa-plugin/passwordless_test.go` prints 1 and `grep -c '^func TestFailureCap' ../sm-mfa-plugin/challenge_test.go ../sm-mfa-plugin/email_test.go ../sm-mfa-plugin/gate_test.go | awk -F: '{s+=$2} END {print s}'` prints 1.
+ - `grep -c '^func TestSocialGate' ../sm-sociallogin-plugin/social_gate_test.go` prints 1.
+ - `grep -c 'TestEveryLoginPathIsGated' scripts/check-phase19.6.sh` prints at least 1 and `grep -c 'TestSocialGate' scripts/check-phase19.6.sh` prints at least 1.
+
+ Every second-factor decision, every PHP body and the rule that no first-factor path skips the challenge are pinned by named tests the gate requires.
+
+
+
+ Task 4: The PHP security test mapped method by method, the security review with the explicit passkey section, the whole gate green across seven repositories, and the validation map completed
+ ../sm-mfa-plugin/php_method_map_test.go, ../sm-mfa-plugin/php_port_test.go, ../sm-mfa-plugin/testdata/PHP_METHOD_MAP.md, ../sm-mfa-plugin/testdata/php_methods.txt, ../sm-mfa-plugin/README.md, .planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-SECURITY-REVIEW.md, .planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-VALIDATION.md, scripts/check-phase19.6.sh
+ /media/nvme/dev/jz/wavepath.org/plugins/golem15/user/tests/security/AuthenticationTest.php (whole file), ../sm-sociallogin-plugin/php_method_map_test.go and testdata/PHP_METHOD_MAP.md (pattern), .planning/phases/19.5-social-login-in-sm-user-plugin/19.5-SECURITY-REVIEW.md (structure), the threat_model blocks of 19.6-01..04-PLAN.md, 19.6-VALIDATION.md, 19.6-RESEARCH.md (Security Domain, Assumptions), 19.6-01/02/03 SUMMARYs
+ PHP map. testdata/php_methods.txt lists every `test_*` method of AuthenticationTest.php (extracted with grep at execution, one per line). testdata/PHP_METHOD_MAP.md has one row per method: PHP method | repository | Go test (top-level or Name/subtest) or not-applicable | reason. test_user_005_twofactor_type_confusion maps to `TestPHPUser005` in php_port_test.go (short): a stored or submitted mode that is not one of the three strings is refused by validation and treated as optional by the effective settings, so enforcement never silently turns off through a type mix-up, and the mode compare is on a typed string. The remaining methods map to the Go test or Phase 19.5 test that covers them, or to not-applicable with the reason (for example PIN login not ported; Phase 07 deferral). `TestPHPMethodMap` (short) fails when a method in php_methods.txt has no row, a row names a method not in the file, or a named Go test does not exist in the named repository's test sources (USER_PLUGIN and SOCIALLOGIN_PLUGIN paths overridable by environment, as sociallogin's map test does).
+
+Security review `19.6-SECURITY-REVIEW.md` (19.5 structure): scope and method; one row per threat T-19.6-01..T-19.6-38 from the four plans' threat_model blocks with the mitigation as built, the passing test that pins it (name and repository) or the accepted disposition with its reason; an explicit "D-14 passwordless passkey login" section reviewing the setting default, credential eligibility (passwordless column, UV at registration and login, credProps rk), user-handle binding, enabled and ownership checks, activation, ban and suspension, generic failures, single-use state and the multi-instance caveat, origin and RP ID checks, clone and BE refusal, rate limiting, deactivated-account restore, logging and why no second challenge follows, each pinned to a TestPasswordless or TestWebAuthn subtest; a "D-18 hardening" section for A6, A8, A9 and A10; ASVS L1 V2, V3, V4, V5, V6 and V7 notes from RESEARCH's Security Domain; residual risks (device-code phishing accepted per D-12c, blacklist_grace forks, memory cooler single instance, numeric user handle). A defect found here is fixed in its own commit with a test and listed for the table below.
+
+Gate and validation. Add TestPHPMethodMap and TestPHPUser005 to the mfa stage; run `scripts/check-phase19.6.sh --all` until green (docker NOT RUN allowed only when no daemon answers, recorded). Update 19.6-VALIDATION.md: status validated, nyquist_compliant true, wave_0_complete true, the per-task verification map with one row per task of plans 01-04 (task id, plan, wave, decision, threat ref, secure behaviour, test type, the gate stage and test name, status), Wave 0 items checked, sign-off ticked. In the SUMMARY write `## Post-release fixes`: one row per production commit made by this plan after plan 03's release (repository, commit, files, the released pins that lack it: user tag and the gitlinks in sm-demo-app, sm-bm-app and fonoteka.go; mfa tag and the demo and bm gitlinks; sociallogin tag and the demo and bm gitlinks), or "none". Commits: in sm-mfa-plugin `test: PHP security test map`; in summercms.go `docs(19.6): security review and validation map` (planning docs alone) and the gate update `test(19.6): gate requires the PHP map`. Post a short comment on g15office task `avoroeb4` with the gate result (or record that the tool is unavailable).
+
+ scripts/check-phase19.6.sh --all
+ Non-zero exit: any stage failed, a required test is missing or skipped, the PHP map is incomplete, or docker failed under PHASE196_REQUIRE_DOCKER=1 (without it, "NOT RUN docker" in the output means no daemon answered and is recorded in the SUMMARY).
+ go -C ../sm-mfa-plugin test -count=1 -short -run '^(TestPHPMethodMap|TestPHPUser005)$' -v . 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 2
+ The count of top-level "--- PASS: Test" lines is not 2: the map test or the USER-005 port failed or does not exist.
+
+
+ - `grep -c 'test_user_005_twofactor_type_confusion' ../sm-mfa-plugin/testdata/PHP_METHOD_MAP.md` prints 1 and `grep -c 'TestPHPUser005' ../sm-mfa-plugin/testdata/PHP_METHOD_MAP.md` prints at least 1.
+ - `grep -cE 'T-19\.6-(0[1-9]|[1-3][0-9])' .planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-SECURITY-REVIEW.md` prints at least 38 and `grep -c 'D-14' .planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-SECURITY-REVIEW.md` prints at least 1.
+ - `grep -c 'nyquist_compliant: true' .planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-VALIDATION.md` prints 1 and `grep -c 'check-phase19.6.sh' .planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-VALIDATION.md` prints at least 4.
+ - The SUMMARY has a `## Post-release fixes` heading with a table or the word none.
+
+ The PHP test file is accounted for method by method, every threat is pinned or consciously accepted, the passkey route has its explicit review, and one command proves the phase across every repository.
+
+
+
+ Task 5: Go-ahead to release this plan's post-release fixes to the applications that pin plan 03's release (presented only when Task 4's Post-release fixes table has a row and plan 03's reply did not pre-authorise patch releases)
+ 19.6-04-SUMMARY.md draft (Post-release fixes table), 19.6-03-SUMMARY.md (Task 4 reply, tags), CLAUDE.md (Commits and concurrency)
+
+ - The SUMMARY records the reply verbatim under "Task 5 reply", or "nothing to propagate" when the table is empty, or "pre-authorised by plan 03 Task 4" with the quoted reply.
+ - Before the reply, no new tag exists on the plugins: `T=$(git -C ../sm-mfa-plugin tag --points-at HEAD) && test -z "$T" || test "$T" = "v0.1.0"` exits 0 when HEAD is the released commit.
+
+ May the post-release fixes listed in the SUMMARY ship as patch releases now?
+ Each row of the Post-release fixes table is a production commit in sm-user-plugin, sm-mfa-plugin or sm-sociallogin-plugin made after plan 03 released them; the applications' pins (sm-demo-app tags and gitlinks, sm-bm-app gitlinks, fonoteka.go's user gitlink) do not contain it yet. Releasing means: push the plugin, an annotated patch tag, the dependent plugin's require bump if any, then separate pointer-bump commits in the applications, pushed (fonoteka.go committed locally only). Holding leaves the fixes unreleased with a todo and a g15office task.
+
+
+
+
+ Reply "release" (optionally with tags) or "hold". Never auto-approved: tags and pushes always need the user.
+
+
+
+ Task 6: Under the Task 5 reply, every post-release fix reaches each application pin in D-17 order: patch tags, dependent requires, then separate pointer-bump commits in sm-demo-app, sm-bm-app and fonoteka.go
+ 19.6-04-SUMMARY.md records "release" (or the pre-authorisation) under Task 5 reply; on "hold" or "nothing to propagate" this task only writes the todo (hold) or records "not needed".
+ ../sm-mfa-plugin/go.mod, ../sm-mfa-plugin/go.sum, ../sm-sociallogin-plugin/go.mod, ../sm-sociallogin-plugin/go.sum, ../sm-demo-app/plugins/golem15/mfa, ../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/mfa, ../sm-bm-app/plugins/golem15/user, ../sm-bm-app/plugins/golem15/sociallogin, ../fonoteka.go/plugins/golem15/user, .planning/todos/pending/mfa-post-release-fixes.md
+ 19.6-04-SUMMARY.md draft (table, reply), 19.6-03-SUMMARY.md (tags and pins), 19.6-03-PLAN.md Tasks 5 and 6 (the same procedures), .planning/G15OFFICE.md
+ On release, per row and in D-17 order: sm-user-plugin first (push, annotated patch tag, push tag), then sm-mfa-plugin and sm-sociallogin-plugin (require the new user tag when the fix is there, `go mod tidy`, vet and short tests, commit, push, patch tag), then the applications in separate commits (`chore: bump to `): sm-demo-app (submodule checkout of the tag, go.mod require and go.sum), sm-bm-app (gitlink), fonoteka.go (user gitlink only, local commit, never pushed); push sm-demo-app and sm-bm-app after the same pre-push check as plan 03 Task 5 (fetch, compare the unpushed range to this phase's commits, never force, never deploy). Then for every fix commit F and every pin P listed in its row run `git merge-base --is-ancestor F ` in the plugin repository and record the results. On hold: write .planning/todos/pending/mfa-post-release-fixes.md (the table, the pins lacking each fix, the commands to release them) with a g15office task under the Phase 19.6 milestone task `avoroeb4`, and record each fix as a deviation in the SUMMARY.
+
+ scripts/check-phase19.6.sh --demo && scripts/check-phase19.6.sh --bm && scripts/check-phase19.6.sh --fonoteka
+ Non-zero exit: an application no longer builds or passes at the new pins, a required test is missing, or fonoteka.go gained a second-factor module. On hold or nothing-to-propagate this command still runs and must pass at the existing pins.
+
+
+ - On release: for every row, `git -C merge-base --is-ancestor ` exits 0 for each pin (results listed in the SUMMARY), and `L=$(git -C ../sm-demo-app log --oneline origin/master..master) && test -z "$L"` exits 0 after the push.
+ - On hold: `test -f .planning/todos/pending/mfa-post-release-fixes.md` exits 0 and the SUMMARY names the g15office task.
+
+ No application runs a release that lacks a fix this phase made, or the gap is tracked as a todo with its release commands.
+
+
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+|----------|-------------|
+| test results to the phase verdict | a skipped, missing or misnamed test must not read as a pass |
+| released plugins to application pins | a fix must reach every application that pins the released code |
+| new login paths to the gate | any future token mint that skips the second factor must be caught |
+
+## STRIDE Threat Register
+
+| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
+|-----------|----------|-----------|----------|-------------|-----------------|
+| T-19.6-36 | Tampering | scripts/check-phase19.6.sh verdict | medium | mitigate | the go test -json detector fails on fail, skip, zero matches, "no tests to run" and missing names, a subtest pass never counts for its parent, the vitest detector needs the named file; --self-test proves each on planted output; docker NOT RUN is explicit and fatal with PHASE196_REQUIRE_DOCKER=1 |
+| T-19.6-37 | Tampering | post-release fixes not reaching applications | medium | mitigate | Task 4's table lists every production fix against every pin; Task 6 releases in D-17 order under the Task 5 reply and checks each pin with merge-base --is-ancestor; hold records a todo and a g15office task |
+| T-19.6-38 | Elevation of privilege | a token mint path that bypasses the second factor (assumption-delta debt) | high | mitigate | TestEveryLoginPathIsGated drives every first-factor path to a challenge and documents each ungated mint by decision; the security review lists the accepted debt from plan 01's assumption_delta_decision |
+| T-19.6-SC | Tampering | Go module installs | high | mitigate | this plan installs no module; the mfa stage pins the three approved versions and refuses the archived YAML module; the fonoteka stage refuses second-factor modules in fonoteka.go's graph |
+
+
+
+- Task 1: the gate's syntax, self-test and the go, userplugin, mfa and sociallogin stages.
+- Task 2: userplugin stage with the new names; short table tests under -race; docker stage (or NOT RUN).
+- Task 3: mfa and sociallogin stages; seven short decision tests; docker stage (or NOT RUN).
+- Task 4: `scripts/check-phase19.6.sh --all`; TestPHPMethodMap and TestPHPUser005; the review and the validation map.
+- Task 5: the reply recorded. Task 6: demo, bm and fonoteka stages at the final pins.
+- Feedback sampling (19.6-VALIDATION.md): after each commit, vet and the short suite of the touched repository.
+
+
+
+- SC-4: every new piece of the phase has unit tests in the last plan, and the phase has its security review with the explicit D-14 section.
+- The gate passes across all seven repositories (docker NOT RUN only where no daemon answers, recorded).
+- The PHP security test file is mapped method by method.
+- No application pins a release that lacks a fix, or the gap is a tracked todo.
+
+
+
diff --git a/.planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-RESEARCH.md b/.planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-RESEARCH.md
index 56d16a0c..7c7558a6 100644
--- a/.planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-RESEARCH.md
+++ b/.planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-RESEARCH.md
@@ -919,19 +919,25 @@ if err == nil {
| A12 | `@simplewebauthn/browser` consumes `options.publicKey` | §Q6 | Frontend wiring detail only |
| A13 | Go modules' legitimacy (seam cannot check Go) | Package Legitimacy Audit | Mitigated by the human check |
-## Open Questions
+## Open Questions (RESOLVED)
+
+All four were settled on 2026-10-21; see CONTEXT D-18 to D-20.
1. **Show the first recovery-code set?**
- What we know: PHP generates codes on first enable and never returns them (TwoFactorService.php:599-608). The user only sees codes after `regenerate`.
- What's unclear: whether the Go port should add `recovery_codes` to the first-enable responses (additive key) or keep PHP's behaviour.
- Recommendation: add `recovery_codes` only when a new set was created by that call. It is additive and the safer UX. Needs user confirmation.
+ - **RESOLVED:** adopted as D-19.
2. **Server-side "enforced" mode?**
- What we know: PHP only reports `enforced` in `status`.
- Recommendation: keep parity (frontend nags); a server-side block would be a new contract.
+ - **RESOLVED:** parity kept; `enforced` is informational only.
3. **Do bm and demo need a 2FA UI this phase?**
- What we know: D-17 mounts mfa; there is no frontend work listed. With no UI, users cannot enable 2FA, so nothing changes.
- Recommendation: API-only in this phase. The demo's Vue app could get a minimal TOTP page later.
+ - **RESOLVED (overrides the recommendation):** D-20, the demo gets a 2FA UI in vue-demo-app; sm-bm-app stays API-only.
4. **Hardening items A6, A8, A9, A10** need a yes/no from the user, since they are security-relevant deviations from PHP (Security Domain lists them).
+ - **RESOLVED:** all four adopted as D-18.
5. **Release order vs 19.5:** 19.5 is complete (sociallogin v0.1.2 at `9fd58db` requires user plugin v0.1.5), so the D-09 gate call can land in sociallogin in this phase.
## Environment Availability