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
new file mode 100644
index 00000000..56d16a0c
--- /dev/null
+++ b/.planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-RESEARCH.md
@@ -0,0 +1,1055 @@
+# Phase 19.6: Two-factor auth and device flow in sm-user-plugin - Research
+
+**Researched:** 2026-10-21
+**Domain:** Second factors (TOTP, WebAuthn/passkeys, email codes, recovery codes, trusted devices) in a new shared plugin `sm-mfa-plugin`, a login-gate seam and a QR/short-code device authorization flow in `sm-user-plugin`
+**Confidence:** HIGH for the PHP contract, the Go seams and the library facts (all read or measured this session). MEDIUM for browser-side WebAuthn behaviour, which only a real authenticator can confirm.
+
+
+## User Constraints (from CONTEXT.md)
+
+### Locked Decisions
+
+#### Placement and libraries
+- **D-01:** **2FA lives in a separate plugin**: repo `sm-mfa-plugin`, Go plugin `golem15.mfa`, which `Require()`s the user plugin. This follows the 19.5 sociallogin and 19.4 oauthserver pattern. Apps without 2FA carry no WebAuthn/CBOR/COSE dependencies, no extra tables and no `/2fa` routes. The repo layout and commit order follow `.planning/notes/core-plugins-own-repos.md`. — **Reversibility:** costly — apps' `summer.yaml`, submodules and settings reference the plugin once it ships.
+- **D-02:** **Login-gate seam in sm-user-plugin.** This is an additive registry of gates. `Login` consults them after `CheckAndRecordLogin` succeeds (`controllers/api_controller.go:94-101`) and before `mintFor` (`:115`).
+ - A gate may return a challenge payload. `Login` then answers HTTP 200 `{two_factor_required:true, challenge_token, available_methods[], expires_at}` (PHP `ApiController.php:55-76`) instead of a token.
+ - With no gate registered, login behaves exactly as today.
+ - The mfa plugin registers the gate and mounts the challenge-completion routes, which mint the token through the user plugin's exported helpers.
+ - sociallogin's `oauth-complete` consults the same gate (D-09).
+ - JWT refresh never consults the gate, as in PHP.
+- **D-03:** The **device authorization flow stays in sm-user-plugin**. It is small and dependency-free, and its paths are already under `/_user/api/v1`.
+- **D-04:** **Libraries** (a decision note under `.planning/notes/` names each with its pinned version before code lands; `.planning/research/STACK.md` is updated):
+ - TOTP: `github.com/pquerna/otp`. Issuer is the app name, the account is the user's email, the window is ±1 step, and digits and period stay at the defaults as in PHP.
+ - WebAuthn: `github.com/go-webauthn/webauthn`. The RP ID is the app URL's host and the RP name is the app name, as in PHP.
+ - QR: the **researcher picks** a library that can emit SVG, so the TOTP setup contract `{secret, qr_code_svg, qr_code_url}` stays.
+
+#### Contract: verbatim vs hardened
+- **D-05:** **Route paths, request fields and response shapes follow PHP** (`routes.php:303-336`, `:372-453`; `TwoFactorApiController.php`):
+ - Challenge group: `/_user/api/v1/2fa/{verify,recovery,email/send,webauthn/options,webauthn/verify}`, unauthenticated, in the **`2fa-verify` bucket (10/min per IP)**.
+ - Management group: `jwt.auth`, with `status`, `totp/setup|confirm`, `DELETE totp`, `email/enable`, `DELETE email`, `webauthn/register/options`, `webauthn/register`, `DELETE webauthn/{id}`, `GET recovery-codes?password=`, `POST recovery-codes/regenerate`, `DELETE trusted-devices/{id}` and `DELETE trusted-devices`.
+ - The method list and the trusted-device list stay inside `status`.
+ - Challenge: TTL 5 min, `max_attempts` 5, and every verify increments the counter. Recovery codes are always accepted through `/recovery`.
+ - Recovery codes: 10 per set, format `XXXXX-XXXXX` over the alphabet `23456789ABCDEFGHJKMNPQRSTUVWXYZ`, stored hashed, and listing returns only the remaining count.
+ - Email code: 6 digits stored hashed, template `two_factor_code` with vars `name`, `code`, `expiry_minutes`, registered with `-en` variants as the user plugin's mail templates are.
+ - Events: the PHP names (`golem15.user.2fa.challenged|failed|recovery_used|verified|enabled|disabled|webauthn.registered|webauthn.removed|recovery_regenerated`), then the user login event after verify.
+- **D-06:** **Tokens at rest are SHA-256 hashes**: challenge tokens, trusted-device tokens and device-auth session tokens (the same `sha256Hex` scheme as wristband and API tokens). Clients see no difference. A future Winter import hashes the plaintext PHP rows on the way in. The TOTP secret is encrypted (`lagoon.Encrypted`). Recovery codes and email codes keep a slow hash; research picks bcrypt or argon2id to match the user plugin's password hashing.
+- **D-07:** **Revocation on security events goes beyond PHP**:
+ - Any password change revokes all trusted devices: API change, reset or admin set. PHP only does this for a CMS password change.
+ - Disabling the user's last 2FA method revokes all trusted devices.
+ - Regenerating recovery codes invalidates the old set, as in PHP.
+
+ The user plugin fires the password-change hook (additive), and mfa listens.
+- **D-08:** **WebAuthn JSON uses go-webauthn's standard shapes**: base64url `PublicKeyCredentialCreationOptions`, `PublicKeyCredentialRequestOptions` and the attestation/assertion responses that `navigator.credentials` and `@simplewebauthn/browser` produce. The paths stay PHP's. This is a **deliberate, recorded deviation** from lbuchs' plain-base64 layout, acceptable because no client implements it yet. Attestation is `none`, the timeout is 60 s, and user verification is `preferred` for second-factor use. — **Reversibility:** costly — clients built against the shape would break.
+- **D-09:** **Social login and 2FA.** A backend setting (D-12) decides whether `oauth-complete` goes through the challenge. The **default is skip, as PHP does**. When enabled, sociallogin's `oauth-complete` consults the login gate and returns the same `two_factor_required` shape instead of `{token,user,…}`. The SPA callback page must handle that shape before an app turns the setting on.
+- **D-10:** **Trusted devices**: off by default (`trusted_device_enabled=false`), 30 days. With `trust_device` on a successful verify, the response adds `trusted_device_token` (PHP `TwoFactorApiController.php:578-588`), and the client sends it back in the login body. The CMS cookie variant (`g15_trusted_device`) is not ported, since there is no CMS.
+
+#### Device flow (sm-user-plugin)
+- **D-11:** **PHP's custom QR plus short-code flow is kept** (not RFC 8628), so QuestStream's `useDeviceAuth.ts` keeps working:
+ - Routes inside the `user-api` group: `POST devices/initiate`, `GET devices/status/{token}`, `POST devices/authorize` (JWT, accepts `short_code` or `token`), `GET devices` and `DELETE devices/{id}`.
+ - A 64-char token and an `XXXX-XXXX` short code (recovery alphabet), with a 5 min TTL.
+ - `auth_url` is a **configurable frontend URL template** (default `{frontend_url}/device/scan/{token}`), because Go has no CMS page.
+ - The model is `DeviceAuthSession`, with PHP's status enum `pending|confirmed|used|expired|revoked`.
+- **D-12a:** **The polling device gets a one-time JWT.** On the first poll after confirmation, `devices/status/{token}` returns PHP's `{status:'confirmed', user_id}` plus `token` and `user`. An atomic `confirmed→used` transition guarantees that only one poll receives it, and later polls see `used`. PHP's API never issued a token (only its CMS component logged the device in), so this is an additive extension without which the API flow cannot sign anything in.
+- **D-12b:** **Revoking a device session blacklists the issued token's JTI**, and its refresh lineage, on the existing JWT blacklist. The session row stores the JTI. A revoked TV or desktop is signed out at once.
+- **D-12c:** **Authorizing needs only a valid JWT, as in PHP.** The authorizing user already passed login and 2FA, and the device binds to them. The frontend shows the device name and IP before confirming. There is no 2FA re-check.
+
+#### Modes, settings and admin (mfa)
+- **D-12:** **A backend Settings screen in mfa** (`pact.HasSettings`) holds PHP's keys and defaults, which config supplies as defaults:
+ - `two_factor_mode`: `disabled|optional|enforced`, default `optional`
+ - `two_factor_available_methods`: default `[totp,email]`
+ - `two_factor_enforce_groups`
+ - `two_factor_email_code_ttl`: 10
+ - `two_factor_challenge_ttl`: 5
+ - `two_factor_passwordless_login`: default off
+ - `trusted_device_enabled`: off
+ - `trusted_device_ttl_days`: 30
+ - **new** social-login challenge toggle: default off/skip
+
+ Changes apply without a restart.
+- **D-13:** **The child-account exemption is dropped.** The Go user plugin has no child-account concept, so only `mode=disabled` exempts. Per-group exemptions may come much later; see deferred.
+- **D-14:** **Passwordless passkey login ships as API routes now**: `/_user/api/v1/2fa/webauthn/passwordless/options` and `.../verify` (exact paths are the planner's call within the `/2fa` prefix and the `2fa-verify` bucket). They issue a JWT directly. Rules:
+ - Only **discoverable credentials registered with user verification `required`** may be used. Second-factor-only credentials are rejected.
+ - A successful passkey login satisfies 2FA, so no second challenge follows.
+ - It is gated by `two_factor_passwordless_login`, off by default.
+ - It still enforces activated/not-banned and the login event.
+
+ It gets an explicit security review. — **Reversibility:** costly — once users register passkeys, removing the route strands them.
+- **D-15:** **Admin "Reset 2FA" on the Users screen**, behind a new backend permission. It disables all methods, deletes the WebAuthn credentials and recovery codes, revokes trusted devices and fires `golem15.user.2fa.disabled`. It is additive: a cabana action contributed by mfa to the user plugin's Users screen. The mechanism (a cabana extension hook vs a new action) is researched, and if the user plugin needs a seam, it is additive.
+
+#### Tables
+- **D-16:** The six PHP tables are ported with PHP's names and columns (`golem15_user_two_factor_methods`, `_two_factor_challenges`, `_two_factor_recovery_codes`, `_webauthn_credentials`, `_trusted_devices` in mfa; `golem15_user_device_auth_sessions` in sm-user-plugin), adjusted for D-06 hashing and the D-12b JTI column. The names stay so a later Winter import is a near-straight copy.
+
+#### Adoption
+- **D-17:** **sm-demo-app and sm-bm-app** mount mfa in this phase, and both get the device flow through the user plugin pin bump. fonoteka.go does not mount mfa. Its guard test `fonoteka/phase07_coverage_test.go:79` (`TestNoDeferredRoutesResolve`) fails once `/devices` routes resolve, so it is updated in the fonoteka pointer-bump commit. Commit order: sm-user-plugin → sm-mfa-plugin and sociallogin pushed → app pointer bumps as separate commits. Push, tag and repo creation need the user's go-ahead.
+
+### Claude's Discretion
+- Go identifiers for the gate interface, registry and payloads.
+- The QR library (research, D-04).
+- Slow-hash choice for codes (D-06).
+- Exact passwordless route names inside `/2fa` (D-14).
+- How the admin reset action plugs into the Users screen (D-15).
+- Whether 19.6 lands before or after 19.5's sociallogin release. If before, the D-09 gate call in sociallogin is added when sociallogin exists.
+
+### Deferred Ideas (OUT OF SCOPE)
+- Per-group 2FA exemptions (settings on user groups), replacing PHP's child-account exemption. Long term.
+- Notification mails on 2FA enabled/disabled or a new trusted device (not in PHP).
+- Winter import of the 2FA and device tables (Phase 30 `import:winter`, numbered 20 when this was written, hashing plaintext tokens per D-06).
+
+
+
+## Phase Requirements
+
+No REQUIREMENTS.md IDs are mapped to this phase (TBD). The CONTEXT decisions and the four roadmap success criteria are the source items.
+
+| ID | Description | Research Support |
+|----|-------------|------------------|
+| SC-1 | TOTP, WebAuthn, email codes, recovery codes, trusted devices against the PHP contract; WebAuthn decision note first | §Q1 libraries (pins, size, vulns), §Q4 PHP contract tables, Pitfalls 5-12, Code Examples 3-6 |
+| SC-2 | Login with a second factor returns the challenge, rate-limited by `2fa-verify` | §Q5 gate placement, Pattern 1, Code Example 1, Pitfall 1 |
+| SC-3 | Device authorization on `DeviceAuthSession` | §Q4 device table, Pattern 4, Code Example 7, Pitfalls 13-16, §Q7 refresh lineage |
+| SC-4 | Six models + migrations, security review, unit tests last | §Q4 tables, §Q6 extra WebAuthn columns, Validation Architecture, Security Domain |
+| D-01 | Separate `sm-mfa-plugin` | §Recommended Project Structure (copy of the sociallogin layout) |
+| D-02 | Login-gate registry | Pattern 1, Code Example 1, Pitfall 1 (throttle reset), Pitfall 2 (body already consumed) |
+| D-04 | Libraries, QR pick | §Q1, §Q2, Package Legitimacy Audit |
+| D-05 | Contract | §Q4 (verbatim messages and shapes), Pitfall 4 (PHP attempt off-by-one) |
+| D-06 | Hashing, slow hash pick | §Q3 (bcrypt via `bouncer.HashPassword`, measured), Pitfall 9 |
+| D-07 | Revocation | §Q5 password-write sites, Pattern 5, Pitfall 3 (rehash is not a change) |
+| D-08 | Standard WebAuthn JSON | §Q6 envelopes, Pitfall 6 (server-side ceremony state), Pitfall 7 (RP ID and origins) |
+| D-09 | Social login gate | §Q5 (token is minted at callback time), Pattern 1 |
+| D-10 | Trusted devices | §Q4, Pitfall 2 |
+| D-11/D-12a/b/c | Device flow | §Q4, §Q7, Pattern 4, Code Example 7, Pitfalls 13-16 |
+| D-12 | Settings screen | §Q8 (no `checkboxlist`/`radio` in cabana settings) |
+| D-13 | No child exemption | §Q4 (`isEnabledForUser`) |
+| D-14 | Passwordless | §Q6, Code Example 6, Security Domain |
+| D-15 | Admin reset | §Q9 (cabana has no cross-plugin action hook; user-plugin slot recommended) |
+| D-16 | Tables | §Q4 (PHP columns verbatim), §Q6 (columns go-webauthn needs) |
+| D-17 | Adoption | §Q10 |
+
+
+## Project Constraints (from CLAUDE.md)
+
+From `summercms.go/CLAUDE.md`, the meta-repo `CLAUDE.md` and the user's global instructions. These carry the weight of locked decisions.
+
+- **Standard library first. A new dependency needs a research doc or a phase/decision note naming it** (`.planning/research/STACK.md` holds the decided set). pquerna/otp, go-webauthn and the QR library need `.planning/notes/-mfa-otp-webauthn.md` **before** they land in any `go.mod` (D-04).
+- YAML is parsed with `goccy/go-yaml`, never `gopkg.in/yaml.v3`. go-webauthn's go.mod lists `go.yaml.in/yaml/v3` (indirect, test-only); it is not in the import graph of the packages the plugin uses (measured, §Q1).
+- **Core plugins must not get breaking changes** (global and meta CLAUDE.md, `core-plugins-own-repos.md`): every sm-user-plugin change is additive. Routes, payloads, tables, migration IDs and config keys stay.
+- **Framework code never names a consuming application.** Plugin READMEs say "the application".
+- A change to exported API, config keys, CLI commands or dependencies updates the module's `README.md` (and `docs/` pages for framework modules) **in the same commit**. Plugin READMEs follow the standard structure (H1, summary, import line, `**Name:**`, Overview, Features, Usage, API reference, Configuration, Dependencies, Testing). New plugin needs a beach-themed `**Name:**` mnemonic.
+- `go vet ./...` and `go test ./...` green at every commit. Docker tests skip under `-short`; a container-starting `TestMain` calls `flag.Parse()` and checks `testing.Short()`. If the Docker socket is denied, run through `sudo -u golem`.
+- **Lean planning:** few large plans; present the plan count with one-line scopes and wait for confirmation before writing PLAN.md files. **Unit tests are the last plan.**
+- Update `STATE.md` only through `msd_run query state.*`. Mirror the phase in g15office.
+- **No co-author tags.** One logical change per commit; planning docs and code separate. Stage by explicit path, never `git add -A`. Other sessions edit concurrently (STATE.md, state.json, cabana).
+- Shared plugins: commit and push the plugin first, then the pointer bump in the app as a separate commit. Never stage files inside a submodule from the app repo. Use `ssu` for submodule management.
+- Don't push, tag, create repos or deploy without the user's go-ahead. A pushed release tag of the **framework** gets a summercms.io changelog post the same day (this phase needs no framework release under the recommended design, §Q9).
+
+## Summary
+
+The phase splits cleanly. **sm-user-plugin** (currently `v0.1.5`, all three relevant app checkouts at `35cf166`) gets five additive seams: a login-gate registry consulted in `Login`, a login event (Go has none today), a password-changed event fired from the three password-write paths, a `reset_two_factor` record-action slot on the Users screen, and the device authorization flow (one migration, one model, five routes, plus a JTI hand-over in `Refresh`). Everything else lives in the new **sm-mfa-plugin**, built on the sociallogin layout (`go.work` replacing the framework and the user plugin, a testcontainers `freshDB` harness, a singleton settings row, `pact.HasSettings`).
+
+The libraries are settled and measured. `github.com/pquerna/otp v1.5.0` (Apache-2.0) and `github.com/go-webauthn/webauthn v0.18.2` (BSD-3-Clause, published 2026-09-19) compile together with **`github.com/boombuler/barcode v1.1.0`** for the QR code. boombuler is already a requirement of pquerna/otp, so picking it adds no module, and a 25-line SVG serializer over its module matrix produced a code that an independent decoder read back byte-for-byte. Linked into a copy of the real sm-demo-app build, the three libraries add **2,269,184 bytes (+2.16 MiB)** to a stripped binary (51,802,375 → 54,071,559) and nine linked modules. They also bump `go-viper/mapstructure/v2` from v2.4.0 to v2.5.0 in the app graph. `govulncheck v1.9.0` found no reachable vulnerabilities. For the slow hash, use **bcrypt through `bouncer.HashPassword`/`CheckPassword`** at `golem15.user.password.bcrypt_cost` (default 10): it matches the user plugin, and ten compares measured 631 ms.
+
+Research found nine things the CONTEXT did not anticipate. Each needs a plan task, and three need user confirmation:
+
+1. **There is no refresh lineage.** `bouncer.Refresh` mints a fresh `jti` and blacklists the old one. Nothing links the two, so blacklisting the stored JTI (D-12b) misses a device that has refreshed once. Fix: the user plugin's `Refresh` handler moves the session row's JTI forward (`UPDATE … SET jwt_jti = new WHERE jwt_jti = old`).
+2. **Go has no login event.** D-05 and D-14 both require "the login event"; `classes/events.go` declares none. An additive `classes.LoginEvent` is needed.
+3. **The password step resets the login throttle before the second factor runs** (`CheckAndRecordLogin(…, true)` zeroes `attempts`). Each new challenge then gets a fresh 5 attempts, so the `2fa-verify` bucket is the only brute-force limit. A per-user failure cap in mfa is recommended (Security Domain).
+4. **cabana cannot add a record action to another plugin's controller.** Record actions must be named in the owner's `config_form.yaml`, and their permissions are validated at boot. D-15 therefore needs a user-plugin slot and a permission code the user plugin registers.
+5. **go-webauthn must persist more than the PHP columns hold** (backup-eligible/state flags, UV-initialized, AAGUID, attestation format), and it refuses a login whose BE flag changed. PHP stores PEM public keys and go-webauthn needs COSE, so PHP credentials are not importable as-is.
+6. **WebAuthn origins:** the RP ID must be a registrable suffix of the *frontend* origin, and IP-address hosts are refused by browsers. The demo's `url: http://127.0.0.1:8097` cannot run a ceremony; split-domain SPAs need an RP ID and origin override.
+7. **PHP registration trusts a client-echoed challenge** (`webauthnRegister` takes `challenge` from the body). The Go port keeps ceremony state server-side (cooler `Pull`), and keeps `challenge` only as a lookup key.
+8. **sociallogin mints its token at callback time**, not in `oauth-complete`. The D-09 gate goes into `signIn`, and the completion payload carries the challenge instead of a token.
+9. **PHP never shows the first recovery-code set** (`ensureRecoveryCodes` discards the plaintext on first enable). Keep it, or return the codes additively? (Open Question 1.)
+
+**Primary recommendation:** Pin pquerna/otp v1.5.0, go-webauthn v0.18.2 and boombuler/barcode v1.1.0 in a decision note. Add the five user-plugin seams first in one additive release. Then build mfa with every state change as a single guarded SQL `UPDATE` (attempts, completion, recovery consume, device confirm/used) and every ceremony state server-side.
+
+## Architectural Responsibility Map
+
+| Capability | Primary Tier | Secondary Tier | Rationale |
+|------------|-------------|----------------|-----------|
+| Password check, throttle, gate consultation | API / Backend (sm-user-plugin `Login`) | — | The gate must run before any token exists |
+| Challenge creation, method verification, token mint after verify | API / Backend (mfa) | Database (challenge row, guarded UPDATEs) | Attempts and one-time completion are enforced in SQL |
+| TOTP secret storage | Database (`lagoon.Encrypted`) | API | Encrypted at rest (D-06) |
+| WebAuthn ceremony (options → assertion) | Browser (`navigator.credentials`) | API (go-webauthn verify), Cache (ceremony state via `cooler.Pull`) | The authenticator runs in the browser; verification and state stay server-side |
+| Email code delivery | API (mfa) → `postcard.Mailer` | — | Code hashed on the challenge row |
+| Trusted-device token | Browser/Client (stores opaque token) | Database (SHA-256 hash, expiry) | Token is a bearer secret; only its hash is stored |
+| Device QR/short code display | Browser/Client (new device) | API (initiate/status) | The new device has no session |
+| Device authorization | Browser/Client (signed-in phone) | API (authorize, JWT) | The authorizer's JWT binds the device |
+| Device session revocation | API (user plugin) | Database (JWT blacklist) | Revocation must reach every server instance |
+| 2FA settings | Database (singleton row) | Config defaults | Changes without restart (D-12) |
+| Admin reset | API (cabana record action in user plugin slot) | mfa (implementation via published service) | YAML-declared actions belong to the controller owner |
+
+## Answers to the research questions
+
+### Q1. Libraries: versions, import graph, size, vulnerabilities
+
+**Pins** [VERIFIED: `go list -m -json @latest` against proxy.golang.org, this session]
+- `github.com/pquerna/otp v1.5.0`, published 2024-12-31T23:15:27Z, `go 1.12`, requires `github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc`. 2,963 stars, Apache-2.0, last push 2025-08-07, not archived. [VERIFIED: pquerna/otp@v1.5.0/go.mod; `gh api repos/pquerna/otp`]
+- `github.com/go-webauthn/webauthn v0.18.2`, published 2026-09-19T12:32:05Z, `go 1.26.0`. 1,351 stars, BSD-3-Clause, last push 2026-10-20, not archived. [VERIFIED: go.mod read; `gh api`]
+- `github.com/boombuler/barcode v1.1.0`, published 2025-07-22T22:10:49Z. 1,563 stars, MIT, last push 2026-10-18. [VERIFIED]
+
+**go-webauthn v0.18.2 direct requirements** [VERIFIED: go-webauthn@v0.18.2/go.mod]:
+```
+github.com/fxamacker/cbor/v2 v2.9.4
+github.com/go-viper/mapstructure/v2 v2.5.0
+github.com/go-webauthn/x v0.3.1
+github.com/golang-jwt/jwt/v5 v5.3.1
+github.com/google/go-tpm v0.9.8
+github.com/google/uuid v1.6.0
+github.com/stretchr/testify v1.12.1
+github.com/tinylib/msgp v1.6.4
+go.uber.org/mock v0.6.0
+```
+`golang-jwt/jwt/v5 v5.3.1`, `google/uuid v1.6.0` and `golang.org/x/crypto v0.57.0` are already in the framework and app graphs at the same versions. `go-viper/mapstructure/v2` is at `v2.4.0 // indirect` in summercms.go, sm-demo-app and sm-bm-app, so minimal version selection bumps it to v2.5.0. [VERIFIED: grep of the three go.mod files plus `go version -m` on the probe binary showing `github.com/go-viper/mapstructure/v2 v2.5.0`]
+
+**Binary size and linked modules** [VERIFIED: measured this session]. Method: a scratch `go.work` uses every sm-demo-app module plus a probe module that drives `totp.Generate`, `totp.Validate`, `qr.Encode`, `webauthn.New`, `BeginRegistration`, `BeginLogin`, `FinishRegistration`, `FinishLogin`, `FinishPasskeyLogin` and `protocol.ParseCredentialCreationResponseBody`. An `-overlay` file wires it into the demo `main` package, and both binaries are built with `-trimpath -ldflags="-s -w"`. No repository file was touched.
+- Base 51,802,375 bytes → with libraries 54,071,559 bytes: **+2,269,184 bytes (+2.16 MiB)**.
+- Newly linked modules (`go version -m` diff): `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`.
+- This is the cost D-01 keeps out of apps that don't mount mfa. fonoteka.go and the other apps never link these modules.
+
+**Vulnerabilities** [VERIFIED: `govulncheck@v1.9.0 -show verbose` on the probe module]: "Your code is affected by 0 vulnerabilities." One module-level advisory, GO-2026-5932 (`golang.org/x/crypto/openpgp` unmaintained), is in a package nothing imports. x/crypto v0.57.0 is already the framework's version.
+
+**TOTP URL format** [VERIFIED: probe run]: `otpauth://totp/Acme:a@b.c?algorithm=SHA1&digits=6&issuer=Acme&period=30&secret=NJWU5QTBSX7QGLWY7ZQ34EGBCZ6XOY4G`. pquerna's default secret is 20 bytes (32 base32 chars). PHP's Google2FA default is 16 base32 chars [ASSUMED]. Clients treat both as opaque.
+
+### Q2. QR code library (D-04, researcher's pick)
+
+**Pick: `github.com/boombuler/barcode/qr` v1.1.0 plus a 25-line SVG serializer in mfa.**
+- It is already a requirement of pquerna/otp (for `Key.Image()`), so the module is in mfa's graph either way. Picking it adds no module, no maintainer and no supply-chain surface.
+- The hard parts of QR are mode selection, Reed-Solomon ECC and masking, and the library does all of them. The plugin only writes `` rectangles for dark modules, which is not cryptographic or protocol code.
+- Proven this session: a TOTP URL encoded at level M, scaled and decoded by the independent `piglig/go-qr` decoder, returned the original URL exactly (`decoded equals URL: true`). The SVG was 17,203 bytes for a 45-module code. [VERIFIED: scratch run]
+
+**Alternative (if the user reads D-04 literally as "the library itself emits SVG"):** `github.com/piglig/go-qr v1.1.0` (2026-05-29, MIT, zero runtime deps, `(*QrCode).ToSVGBytes`). It has 67 stars, one maintainer, and its go.mod carries `gopkg.in/yaml.v3` as a test-only indirect. Prefer boombuler; Assumption A1 records the choice.
+
+PHP renders with Bacon `RendererStyle(200)` + `SvgImageBackEnd`. The Go SVG should declare `width="200" height="200"` with a `viewBox` so it renders at the same size [VERIFIED: TotpMethod.php:42-47 `new RendererStyle(200)`, `new SvgImageBackEnd()`].
+
+### Q3. Slow hash for recovery and email codes (D-06)
+
+**bcrypt through `bouncer.HashPassword(cost, plain)` / `bouncer.CheckPassword(hash, plain)`** at `golem15.user.password.bcrypt_cost` (default `10`, user plugin `config/config.yaml`: `password:` → `bcrypt_cost: 10`). This is exactly the user plugin's password hashing [VERIFIED: summercms.go/modules/bouncer/password.go:6-19; user plugin api_controller.go:102-110]. No new dependency; argon2id would add `golang.org/x/crypto/argon2` usage the framework does not have.
+- Cost: 10 compares at cost 10 took **631 ms** on this machine [VERIFIED: measured]. A recovery verify compares against every unused code (up to 10), so a wrong code costs about 0.63 s of CPU. With the `2fa-verify` bucket at 10/min per IP that is acceptable. Never hash at a cost above the configured one, and cap the recovery-code input length (a 72-byte bcrypt limit applies).
+- Regenerating 10 codes is 10 hashes (~0.6 s). Do it inside one transaction (delete old + insert new).
+
+### Q4. The PHP contract to reproduce
+
+Source: `/media/nvme/dev/jz/wavepath.org/plugins/golem15/user`, files read this session.
+
+**Rate limiter** [VERIFIED: routes.php:16-18]:
+```php
+RateLimiter::for('2fa-verify', function (Request $request) {
+ return Limit::perMinute(10)->by($request->ip());
+});
+```
+
+**Challenge group** [VERIFIED: routes.php:303-337]: `['prefix' => '/_user/api/v1/2fa', 'middleware' => ['throttle:2fa-verify', 'bindings']]` with `Route::post('verify'…)`, `'recovery'`, `'email/send'`, `'webauthn/options'`, `'webauthn/verify'`.
+**Management group** [VERIFIED: routes.php:372-453]: `['prefix' => '/_user/api/v1/2fa', 'middleware' => ['jwt.auth', 'bindings']]` with `Route::get('status')`, `post('totp/setup')`, `post('totp/confirm')`, `delete('totp')`, `post('email/enable')`, `delete('email')`, `post('webauthn/register/options')`, `post('webauthn/register')`, `delete('webauthn/{id}')->where('id', '[0-9]+')`, `get('recovery-codes')`, `post('recovery-codes/regenerate')`, `delete('trusted-devices/{id}')->where('id', '[0-9]+')`, `delete('trusted-devices')`. The management group has **no throttle** in PHP.
+**Device routes** [VERIFIED: routes.php:191-225] inside the `'/_user/api/v1'` group with `'throttle:user-api'`: `get('devices')`, `delete('devices/{id}')->where('id', '[0-9]+')`, `post('devices/authorize')`, `post('devices/initiate')`, `get('devices/status/{token}')`. Authentication happens inside the controller.
+
+**Login challenge** [VERIFIED: ApiController.php:55-76]:
+```php
+if ($twoFactorService->isEnabledForUser($userModel)) {
+ $trustedDeviceToken = $request->get('trusted_device_token');
+ if ($trustedDeviceToken && $twoFactorService->isTrustedDeviceEnabled()
+ && $twoFactorService->checkTrustedDeviceToken($userModel->id, $trustedDeviceToken)) {
+ event('golem15.user.login', [$userModel]);
+ return response()->json(['token' => $token, 'user' => $userModel->getApiArray()]);
+ }
+ JWTAuth::invalidate($token);
+ $challenge = $twoFactorService->createChallenge(
+ $userModel, $request->ip(), $request->userAgent()
+ );
+ return response()->json([
+ 'two_factor_required' => true,
+ 'challenge_token' => $challenge->token,
+ 'available_methods' => $twoFactorService->getEnabledMethods($userModel),
+ 'expires_at' => $challenge->expires_at->toIso8601String(),
+ ]);
+}
+```
+`expires_at` uses `toIso8601String()`, which matches `wire.Time` (`2006-01-02T15:04:05+00:00`) [VERIFIED: summercms.go/modules/wire/response.go:32-44].
+
+**Who is challenged** [VERIFIED: TwoFactorService.php:33-47, 54-69, 74-77]: `isEnabledForUser` is false when `two_factor_mode` is `'disabled'`, otherwise true when any `golem15_user_two_factor_methods` row has `is_enabled = true`. `getEnabledMethods` plucks those methods and drops `webauthn` when no enabled credential exists. `available_methods` in the challenge is the user's enabled methods, **not** filtered by the global `two_factor_available_methods`.
+**"Enforced" is informational only**: `isEnforcedForUser` (:95-109) is used only by `getStatus` (`'enforced' =>`, :586). PHP never refuses a login for a user without 2FA in enforced mode. [VERIFIED: grep of the plugin; only TwoFactorService.php:586 calls it]
+
+**verifyChallenge** [VERIFIED: TwoFactorService.php:134-205]: find a valid challenge → `incrementAttempts()` → `if ($challenge->isExhausted())` fire `golem15.user.2fa.failed` and fail → recovery: `TwoFactorRecoveryCode::verifyAndConsume` + `golem15.user.2fa.recovery_used` with the remaining count; other methods: handler `verify`, `touchLastUsed` → on failure `golem15.user.2fa.failed` → `markCompleted()` → `JWTAuth::fromUser($user)` → `event('golem15.user.login', [$user])` → `Event::fire('golem15.user.2fa.verified', [$user, $method])` → `['token' => $jwtToken, 'user' => $user->getApiArray()]`.
+**Challenge validity** [VERIFIED: TwoFactorChallenge.php:105-133]: not found, `isExpired()`, `isCompleted()` or `isExhausted()` (`$this->attempts >= $this->max_attempts`) → null. `generate()` calls `cleanupExpired($user->id)` first (deletes expired, uncompleted rows) and uses `Str::random(64)`.
+
+**Challenge endpoints** [VERIFIED: TwoFactorApiController.php]
+
+| Endpoint | Validation (Laravel) | Success | Failure |
+|---|---|---|---|
+| `POST 2fa/verify` (:37-66) | `challenge_token` `required\|string\|size:64`, `method` `required\|string\|in:totp,email`, `code` `required\|string\|min:6\|max:6` | 200 `{token, user}` (+ `trusted_device_token`) | 422 `{error, errors}`; 401 `{"error":"Invalid verification code.","two_factor_failed":true}` |
+| `POST 2fa/recovery` (:74-102) | `challenge_token` `required\|string\|size:64`, `code` `required\|string` | 200 `{token, user}` (+ `trusted_device_token`) | 422; 401 `{"error":"Invalid recovery code.","two_factor_failed":true}` |
+| `POST 2fa/email/send` (:110-138) | `challenge_token` `required\|string\|size:64` | 200 `{"message":"Verification code sent.","email":}` | 422 `{error}` (no `errors`); 401 `{"error":"Invalid or expired challenge."}` / `{"error":"Invalid challenge."}` |
+| `POST 2fa/webauthn/options` (:146-176) | `challenge_token` `required\|string\|size:64` | 200 options (stores challenge in `code`, `method = webauthn`) | 422; 401 as above |
+| `POST 2fa/webauthn/verify` (:184-218) | `challenge_token` `required\|string\|size:64`, `assertion` `required\|array` | 200 `{token, user}` (+ `trusted_device_token`) | 401 `{"error":"Invalid or expired challenge. Request WebAuthn options first."}`; 401 `{"error":"WebAuthn verification failed.","two_factor_failed":true}` |
+
+`withTrustedDevice` (:578-588) adds `trusted_device_token` only when `$request->get('trust_device')` is truthy **and** `isTrustedDeviceEnabled()`.
+
+**Management endpoints** [VERIFIED: TwoFactorApiController.php:229-568]. Every one catches auth failure as 401 `{"error":"Unauthorized"}`.
+
+| Endpoint | Success body | Errors |
+|---|---|---|
+| `GET status` | `getStatus($user)` | — |
+| `POST totp/setup` | `{secret, qr_code_svg, qr_code_url}` | 403 `{"error":"TOTP is not available."}` |
+| `POST totp/confirm` (`secret` `required\|string`, `code` `required\|string\|min:6\|max:6`) | `{"message":"TOTP enabled successfully.","status":…}` | 422 validation; 422 `{"error":"Invalid verification code."}` |
+| `DELETE totp` | `{"message":"TOTP disabled.","status":…}` | — |
+| `POST email/enable` | `{"message":"Email 2FA enabled.","status":…}` | 403 `{"error":"Email 2FA is not available."}` |
+| `DELETE email` | `{"message":"Email 2FA disabled.","status":…}` | — |
+| `POST webauthn/register/options` | options | 403 `{"error":"WebAuthn is not available."}` |
+| `POST webauthn/register` (`attestation` array, `challenge` string, `name` `nullable\|string\|max:255`) | `{"message":"Security key registered successfully.","credential":{"id","name" (default "Security Key"),"created_at" ISO-8601},"status":…}` | 422 `{"error":"Registration failed: " . $e->getMessage()}` |
+| `DELETE webauthn/{id}` | `{"message":"Security key removed.","status":…}` | 404 `{"error":"Credential not found."}` |
+| `GET recovery-codes?password=` | `{"recovery_codes_remaining":N,"message":"Recovery codes cannot be viewed after creation. Regenerate to get new codes."}` | 403 `{"error":"Password confirmation required."}` |
+| `POST recovery-codes/regenerate` (`password`) | `{"recovery_codes":[…],"message":"Save these codes securely. They will not be shown again."}` | 403 as above |
+| `DELETE trusted-devices/{id}` | `{"message":"Device revoked.","status":…}` | 404 `{"error":"Device not found."}` |
+| `DELETE trusted-devices` | `{"message":"All trusted devices revoked.","status":…}` | — |
+
+**Status shape** [VERIFIED: TwoFactorService.php:584-593]:
+```php
+return [
+ 'enabled' => $this->isEnabledForUser($user),
+ 'enforced' => $this->isEnforcedForUser($user),
+ 'methods' => $methods->pluck('method')->toArray(),
+ 'webauthn_credentials' => $webauthnCredentials->toArray(),
+ 'recovery_codes_remaining' => $recoveryCodesRemaining,
+ 'available_methods' => UserSettings::get('two_factor_available_methods', ['totp', 'email']),
+ 'trusted_device_enabled' => $this->isTrustedDeviceEnabled(),
+ 'trusted_devices' => $trustedDevices,
+];
+```
+`webauthn_credentials` items: `'id'`, `'name' => $cred->name ?? 'Security Key'`, `'last_used_at'`, `'created_at'`, both via `toIso8601String()` (:559-564). `trusted_devices` items (only when enabled, `trusted_until > now()`, ordered `last_used_at desc`): `'id'`, `'device_name' => $d->device_name ?? 'Unknown Device'`, `'ip_address'`, `'last_used_at'`, `'trusted_until'`, `'created_at'` as raw Carbon values (:569-582). Raw Carbon serializes through `jsonSerialize`, as `2026-10-21T12:00:00.000000Z` [ASSUMED: Carbon 2 `toJSON` format]. That differs from `+00:00`, so a byte-parity test must pin it.
+
+**Recovery codes** [VERIFIED: TwoFactorRecoveryCode.php:49 `const CODE_CHARACTERS = '23456789ABCDEFGHJKMNPQRSTUVWXYZ';`, :59-87 generate, :96-112 verify]:
+- `generateForUser` deletes all existing codes first, then builds `$part1 . '-' . $part2` of 5 random chars each and stores `Hash::make($plaintext)`.
+- `verifyAndConsume` normalizes with `strtoupper(str_replace(' ', '', $code))` (the hyphen is kept), then loops unused codes with `Hash::check`.
+- `ensureRecoveryCodes` (TwoFactorService.php:599-608) generates a set on the first enable but **returns nothing**. TOTP confirm, email enable and WebAuthn register never show the plaintext (Open Question 1).
+
+**Method enable/disable** [VERIFIED: TwoFactorService.php:228-294, 322-356]:
+- `confirmTotp` takes the client-sent `secret`, verifies with window 1 and upserts `{is_enabled:true, secret}`.
+- `disableMethod` disables the row, and for webauthn also all credentials. When no method remains, it **deletes recovery codes and trusted devices**.
+- `removeWebAuthnCredential` deletes one credential. With none left it disables the method and fires `golem15.user.2fa.disabled`, then runs the same no-method cleanup.
+
+**TOTP** [VERIFIED: TotpMethod.php:34-54, 60-72]: issuer `config('app.name', 'Golem15')`, holder `$user->email`, `verifyKey($method->secret, $code, 1)`. TOTP verify requires an enabled `totp` row with a secret. There is **no replay protection**.
+
+**Email method** [VERIFIED: EmailMethod.php:52-68, 73-76, 81-98]:
+- `sendCode` stores `Hash::make` of `str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT)` on the challenge (`method = email`). It mails `golem15.user::mail.two_factor_code` with `'name' => $user->name, 'code' => $code, 'expiry_minutes' => $ttl` (`two_factor_email_code_ttl`, default 10) and returns the masked email.
+- The mask is the first char, `*` × (len-2), the last char, `@domain`; a local part of ≤2 chars gives first char + `***`.
+- **`sendCode` and `verify` never check that the user enabled the email method**, and the code expires only with the challenge (5 min), not after `two_factor_email_code_ttl`.
+
+**Mail template** [VERIFIED: views/mail/two_factor_code.htm]: `subject = "Your verification code"`; the body says `Hi {{ name }},` / `Your two-factor authentication code is:` / `**{{ code }}**` / `This code expires in {{ expiry_minutes }} minutes.` / `If you didn't request this code, please secure your account immediately by changing your password.`
+
+**Trusted devices** [VERIFIED: TrustedDevice.php:36-56, 87-124; TwoFactorService.php:483-530]:
+- `createForUser` stores the `Str::random(64)` token, `device_name` from `parseDeviceName($userAgent)` (`"{$browser} on {$os}"`, e.g. `Chrome on Windows`), the IP, and `trusted_until = now + ttlDays`.
+- `checkTrustedDeviceToken` requires `findValidToken` (`trusted_until > now`) and a matching `user_id`, then updates `last_used_at` and `ip_address`.
+
+**Settings** [VERIFIED: models/Settings.php:40-42, 56-63]: constants `TWO_FACTOR_DISABLED = 'disabled'`, `TWO_FACTOR_OPTIONAL = 'optional'`, `TWO_FACTOR_ENFORCED = 'enforced'`. Defaults: `config('golem15.user::twoFactorMode', self::TWO_FACTOR_DISABLED)`, `twoFactorAvailableMethods ['totp', 'email']`, `twoFactorEmailCodeTtl 10`, `twoFactorChallengeTtl 5`, `twoFactorEnforceGroups []`, `twoFactorPasswordlessLogin false`, `trustedDeviceEnabled false`, `trustedDeviceTtlDays 30`. **PHP's default mode is `disabled`**; D-12 locks `optional` for mfa, which makes sense because mounting mfa is the opt-in. Group enforcement compares `$user->groups->pluck('code')` (:107-108); Go has `classes.UserGroupCodes(ctx, db, userID)`.
+
+**Device flow** [VERIFIED: ApiController.php:1175-1347; DeviceAuthSession.php:86-90]:
+- Status constants: `const STATUS_PENDING = 'pending'; const STATUS_CONFIRMED = 'confirmed'; const STATUS_USED = 'used'; const STATUS_EXPIRED = 'expired'; const STATUS_REVOKED = 'revoked';`
+- `initiate` (:1277-1308), unauthenticated. It creates a row with `'token' => Str::random(64)`, a unique short code, `'user_id' => null`, status pending, `expires_at` now+5 min, `device_ip`, `device_user_agent` and `device_name` from `$request->input('device_name')`. Response: `{"success":true,"data":{"token","short_code","auth_url","expires_at" (toIso8601String)}}`.
+- `status/{token}` (:1315-1347):
+ - unknown → 404 `{"error":"Session not found"}`;
+ - expired → `markAsExpired()` and **flat** `{"success":true,"status":"expired","message":"Authorization session has expired"}`;
+ - otherwise `{"success":true,"data":{"status":…}}`, plus `user_id` and `"message":"Device authorized - complete login"` when confirmed.
+- `authorize` (:1233-1270):
+ - 401 `{"error":"Unauthorized"}`;
+ - with `short_code`: strip `-`, length ≠ 8 → 422 `{"error":"Please enter a valid 8-character code"}`, then `findValidShortCode` (uppercases, removes spaces, re-adds the hyphen); otherwise with `token`: `findValidToken`;
+ - nothing found → 400 `{"error":"Invalid or expired code"}`;
+ - success: user_id set only if null, `confirm($request->ip())`, then `{"success":true,"message":"Device authorized successfully"}`.
+- `listDevices` (:1175-1202): statuses confirmed|used, ordered `last_activity_at desc`, items `{id, device_name, device_ip, authorized_at, last_activity, is_current}` → `{"success":true,"devices":[…]}`.
+- `revokeDevice` (:1208-1227): 404 `{"error":"Device not found"}` or `{"success":true,"message":"Device revoked successfully"}`.
+- Short code [VERIFIED: ApiController.php private `generateUniqueShortCode`, DeviceAuthSession.php:142-160]: 8 chars from `'23456789ABCDEFGHJKMNPQRSTUVWXYZ'`, formatted `XXXX-XXXX`.
+- `auth_url` was `url("/device/scan/{$this->token}")` (DeviceAuthSession.php:388-391).
+- **Client** [VERIFIED: vue-queststream-app/composables/useDeviceAuth.ts:55-125]: reads `response.data || response` for both initiate and status, stops polling on `status === 'expired'` or `'confirmed'`, and polls every `2000` ms.
+
+**Tables (PHP columns, verbatim)** [VERIFIED: the migrations under updates/, read this session]
+- `golem15_user_two_factor_challenges` (v3.0.0/create_two_factor_challenges_table.php:13-34): `increments('id')`, `integer('user_id')->unsigned()`, `string('token', 64)->unique()`, `string('method', 20)->nullable()`, `string('code', 255)->nullable()`, `tinyInteger('attempts')->unsigned()->default(0)`, `tinyInteger('max_attempts')->unsigned()->default(5)`, `string('ip_address', 45)->nullable()`, `text('user_agent')->nullable()`, `timestamp('expires_at')`, `timestamp('completed_at')->nullable()`, `timestamps()`, `index('token')`, `index(['user_id', 'expires_at'])`, FK `user_id` → `users.id` `onDelete('cascade')`.
+- `golem15_user_two_factor_methods` (create_two_factor_methods_table.php:13-31): `user_id`, `string('method', 20)`, `boolean('is_enabled')->default(true)`, `text('secret')->nullable()`, `json('metadata')->nullable()`, `timestamp('last_used_at')->nullable()`, `timestamps()`, `unique(['user_id', 'method'])`, `index(['user_id', 'is_enabled'])`, FK cascade.
+- `golem15_user_two_factor_recovery_codes` (create_two_factor_recovery_codes_table.php:13-27): `user_id`, `string('code', 255)`, `timestamp('used_at')->nullable()`, `timestamp('created_at')->nullable()` (no `updated_at`), `index(['user_id', 'used_at'])`, FK cascade.
+- `golem15_user_webauthn_credentials` (create_webauthn_credentials_table.php:13-33): `user_id`, `text('credential_id')`, `text('public_key')`, `string('attestation_type')->nullable()`, `json('transports')->nullable()`, `integer('sign_count')->unsigned()->default(0)`, `string('name')->nullable()`, `boolean('is_enabled')->default(true)`, `timestamp('last_used_at')->nullable()`, `timestamps()`, `index('user_id')`, FK cascade.
+- `golem15_user_trusted_devices` (v3.1.0/create_trusted_devices_table.php:13-31): `user_id`, `string('token', 64)->unique()`, `string('device_name')->nullable()`, `string('ip_address', 45)->nullable()`, `timestamp('trusted_until')`, `timestamp('last_used_at')->nullable()`, `timestamps()`, `index('user_id')`, `index('token')`, FK cascade.
+- `golem15_user_device_auth_sessions`:
+ - v2.1.0 (create_device_auth_sessions_table.php:18-46): `string('token', 64)->unique()->index()`, `integer('user_id')->unsigned()->index()`, `enum('status', ['pending', 'confirmed', 'used', 'expired', 'revoked'])->default('pending')->index()`, `timestamp('expires_at')->index()`, `string('device_ip')->nullable()`, `text('device_user_agent')->nullable()`, `string('device_name')->nullable()`, `string('auth_ip')->nullable()`, `timestamp('confirmed_at')->nullable()`, `timestamp('used_at')->nullable()`, `string('session_id')->nullable()`, `timestamp('last_activity_at')->nullable()`, `timestamps()`, FK cascade;
+ - v2.3.1:16 `string('short_code', 9)->nullable()->unique()->after('token')`;
+ - v2.3.2 makes `user_id` nullable;
+ - v2.7.1:19 `dateTime('expires_at')->nullable()->change()`.
+
+All three token columns are 64 characters wide, and a SHA-256 hex digest is exactly 64 characters, so D-06 hashing fits the PHP column widths unchanged.
+
+### Q5. Go code to extend (sm-user-plugin v0.1.5)
+
+**Where the plugin lives.** There is **no `../sm-user-plugin` sibling directory**. The plugin is checked out as a submodule in each app. `fonoteka.go/plugins/golem15/user`, `sm-demo-app/plugins/golem15/user` and `sm-bm-app/plugins/golem15/user` are all at `35cf166` (= tag `v0.1.5` = `origin/master`). `sm-grzybyfunkcjonalne-app` and `sm-summercmsio-app` are still at `24b16b1` (v0.1.1). sociallogin's `go.work` replaces the user plugin with `../fonoteka.go/plugins/golem15/user`, so work there and push from there (19.5 precedent). [VERIFIED: git log/describe this session]
+
+**Login** [VERIFIED: controllers/api_controller.go:47-127]:
+1. `readFields(r)` (the body is consumed here);
+2. `LookupByEmail`, `RejectIfThrottled`, `bouncer.CheckPassword`;
+3. soft-deleted user → undelete + reactivate mail (:87-93);
+4. `classes.CheckAndRecordLogin(ctx, gdb, user, ip, true)` (:94-101);
+5. bcrypt rehash (:102-110), `TouchLastSeen` (:112-114), `mintFor` (:115), `apiArray` (:120), `writeJSON(w, http.StatusOK, map[string]any{"token": token, "user": payload})` (:125).
+
+D-02 places the gate between step 4 and the mint.
+- `CheckAndRecordLogin(…, true)` runs `Updates(map[string]any{"attempts": 0, "is_suspended": false, …})` [VERIFIED: classes/throttle.go:107-140]. It resets the throttle **before** the second factor (Pitfall 1).
+
+**Other token mints** [VERIFIED: grep + read]:
+- `ActivateByCode` (:425) mints only for a not-yet-activated account (`IsAlreadyActivated` → error page), which cannot have 2FA yet.
+- `Register` (:703) is for a new user.
+- fonoteka's onboarding controller calls `IssueToken` but fonoteka does not mount mfa.
+- sociallogin `signIn` and `linkIdentity` (flow.go:155, :315).
+- cabana's `account.go`/`auth.go` are the backend admin login. Backend-admin 2FA is not in scope.
+
+**Exported helpers** [VERIFIED: registration.go:99-191, api_controller.go:877-899]:
+- `FinishLogin(ctx, app, db, user, ip) error`: restore soft-deleted + reactivate mail, `last_ip_address`, `TouchLastSeen`; "checks no password, throttle, activation or ban and fires no event".
+- `IssueToken(app, user, r) (string, error)`, `APIArray(ctx, app, user) (map[string]any, error)`, `RefreshTTL(app) time.Duration`, `RemoteIP(r) string`.
+- `mintFor` discards the JTI: `token, _, err := bouncer.Mint(secret, strconv.FormatUint(uint64(user.ID), 10), issuer, ttl)`.
+- `bouncer.Mint` returns `(string, string, error)`, and "The returned jti is the token's own jti claim" [VERIFIED: bouncer/mint.go:29-33].
+
+**Events** [VERIFIED: classes/events.go:9-70]: `CreatedEventName = "user.Created"`, `DeletingEventName = "user.Deleting"`, `DeactivatingEventName = "user.Deactivating"`. Types `GetApiArrayEvent`, `RegisterEvent`, `CreatedEvent`, `DeletingEvent`, `DeactivatingEvent`. **No login event, no password event.** The festival bus is typed: `app.Events.Listen[*T](owner, fn)` / `app.Events.Fire(ctx, &T{…})` keyed by `reflect.TypeFor[T]()` [VERIFIED: summercms.go/modules/festival/bus.go:43-84].
+
+**Password-write sites (D-07 hook points)** [VERIFIED: grep of `HashPassword(` + read]:
+- `ResetPassword`: `api_controller.go:327-343`, `Updates(map[string]any{"password": hashed, "tokens_valid_after": now, "email_verified_at": now})`.
+- `ChangePassword`: `:580-597`, `"password": hashed, "must_change_password": false, "has_self_set_password": true, "tokens_valid_after": cutoff`.
+- Admin update: `users_admin_controller.go:238-247`, `changed, err := c.storePassword(ctx, user)` → `user.TokensValidAfter = &now`, inside cabana's write (use `requestDB(ctx, c.db)` for the transaction).
+- **Not a change:** the bcrypt rehash on login (`api_controller.go:106-109`) and `RegisterUser` (registration.go:63).
+
+**Throttle bucket shape** [VERIFIED: plugin.go:150-171]: the `"user-api"` bucket is `Max: 120, Decay: time.Minute`, keyed `"u:"+sub` or `surf.ClientIP(r, trusted)`. Bucket names are global and unique (`surf: bucket %q already registered by %s`, surf/limiter.go:85), so mfa owns `"2fa-verify"`.
+
+**Routes** [VERIFIED: routes.go:17-37]: everything is in `r.Group("/_user/api/v1", surf.Use("throttle:user-api"), …)`. `g.Where("id", "[0-9]+")` follows `g.Delete("/tokens/{id}", …)`, and `Where` "attaches … to the last route" (surf/router.go:279-298; Pitfall 17).
+
+**Mail** [VERIFIED: plugin.go:189-206; classes/mail.go:12-18; postcard/templates.go:115-170, 197-199]:
+- Templates are `"golem15.user::mail.activate"` / `"…-en"` pairs. `MailTemplate(base, locale)` appends `-en` only for `en`; every other locale stays on the unsuffixed (Polish) template.
+- postcard requires the template name to start with the owning plugin's ID (`template %s is not owned by %s`). Layout aliases are per plugin (`c.aliases[pluginID]`), and registering an existing full layout name fails (`duplicate layout`).
+- mfa therefore registers `golem15.mfa::mail.two_factor_code` and `golem15.mfa::mail.two_factor_code-en`, and uses the default layout or ships its own. It cannot reuse the user plugin's `user` alias.
+- Template syntax is Go `text/template` (`{{ .Name }}` in restore-en.htm). The user plugin passes both cases (`"name": name, "Name": name`, api_controller.go:924-929).
+
+**sociallogin** [VERIFIED: sm-sociallogin-plugin/classes/flow.go:128-160, 312-325, codes.go:35-40, controllers/complete.go:17-35, controllers/helpers.go:148-169]:
+- The token is minted in `signIn` (`usercontrollers.FinishLogin` → `usercontrollers.IssueToken`) at **callback** time and stored in `CompletionPayload{Token, User, Action, ReturnTo}`. `Complete` only pulls the code and calls `writeSession`.
+- The D-09 gate therefore belongs in `signIn` (shared by the callback login and `RegisterComplete`), not in `Complete`. The completion payload gets an additive `Challenge map[string]any` field, and `Complete` writes it as a plain 200 JSON when set.
+- `linkIdentity` (`action=link`) issues a fresh token for an already-signed-in user, so it must not be gated.
+
+### Q6. go-webauthn: what the port needs
+
+**API used** [VERIFIED: go-webauthn@v0.18.2 webauthn/{types.go,registration.go,login.go,registration_opt.go,login_opt.go,types_session.go,credential.go}, protocol/{options.go,authenticator.go,assertion.go,credential.go}]
+- `webauthn.New(&webauthn.Config{RPID, RPDisplayName, RPOrigins, AttestationPreference, Timeouts: webauthn.TimeoutsConfig{Login, Registration webauthn.TimeoutConfig{Enforce, Timeout}}})`.
+- Second factor: `BeginRegistration(user, opts…)` / `CreateCredential(user, session, parsed)`, then `BeginLogin(user, opts…)` / `ValidateLogin(user, session, parsed)`.
+- Passwordless: `BeginDiscoverableLogin(opts…)` / `ValidatePasskeyLogin(handler, session, parsed) (User, *Credential, error)`, with `type DiscoverableUserHandler func(rawID, userHandle []byte) (user User, err error)`.
+- The request body is PHP's envelope (`{challenge_token, assertion}`), not a bare credential. Parse the inner object with `protocol.ParseCredentialCreationResponseBytes(data)` / `protocol.ParseCredentialRequestResponseBytes(data)` and use `CreateCredential` / `ValidateLogin` / `ValidatePasskeyLogin`, not the `*http.Request` variants.
+- Options: `WithResidentKeyRequirement(protocol.ResidentKeyRequirementRequired)`, `WithExclusions(…)`, `WithConveyancePreference(protocol.PreferNoAttestation)`, `WithExtensions(WithExtensionCredProps())`, and `WithAllowedCredentials(…)` / `WithUserVerification(protocol.VerificationRequired)` (constants: `ResidentKeyRequirementRequired = "required"`, `VerificationRequired = "required"`, `PreferNoAttestation ConveyancePreference = none`, protocol/authenticator.go:153-211, protocol/options.go:183).
+- JSON envelopes: `protocol.CredentialCreation` = `{"publicKey": PublicKeyCredentialCreationOptions, "mediation"?}`, `protocol.CredentialAssertion` = `{"publicKey": …}` (protocol/options.go:8-21).
+- `webauthn.User` interface: `WebAuthnID() []byte` (≤64 bytes), `WebAuthnName() string`, `WebAuthnDisplayName() string`, `WebAuthnCredentials() []Credential` (types.go:368-400).
+- `SessionData` fields: `Challenge`, `RelyingPartyID`, `Origin`, `UserID`, `AllowedCredentialIDs`, `Expires`, `UserVerification`, `Extensions`, `CredParams`, `Mediation`, `AuthorizeUVInitialization` (types_session.go:66-80). The package doc says it "MUST be definitively anchored to a user's active session", with bytes the RP serialized and kept integrity-protected (doc.go:122-138).
+
+**The credential record needs more than PHP's columns** [VERIFIED: credential.go:105-150, 440-470; login.go:402-408; doc.go:98-120]
+- `Credential` holds `ID`, `PublicKey` (COSE), `AttestationType`, `AttestationFormat`, `Transport`, `Flags{UserPresent, UserVerified (latched uvInitialized), BackupEligible, BackupState}`, `Authenticator{AAGUID, SignCount, CloneWarning, Attachment}`, `Attestation`, `Extensions{RK *bool, …}`.
+- The doc comment says the record "must be written back on every successful FinishLogin / ValidateLogin".
+- Login fails when `credential.Flags.BackupEligible != parsedResponse.Response.AuthenticatorData.Flags.HasBackupEligible()`, so the flags must be stored.
+- Recommended columns, additive to the PHP table:
+ - `credential_data TEXT NULL`: the JSON of `webauthn.Credential`, the library's supported opaque shape, which round-trips every field;
+ - `passwordless BOOLEAN NOT NULL DEFAULT FALSE` (D-14);
+ - a unique index on `credential_id`, needed for the passwordless lookup by raw ID.
+- Keep `credential_id` (base64url of the raw ID), `public_key` (base64url COSE), `attestation_type`, `transports`, `sign_count`, `name`, `is_enabled` and `last_used_at` filled for display, queries and the import.
+- The explicit-column alternative the library doc prefers is the planner's call (Assumption A3).
+
+**PHP credentials are not portable as-is** [VERIFIED: wavepath vendor/lbuchs/webauthn/src/WebAuthn.php:408 `$data->credentialPublicKey = $attestationObject->getAuthenticatorData()->getPublicKeyPem();`; WebAuthnMethod.php:170-179 stores `base64_encode($data->credentialPublicKey)`]. PHP stores a PEM key, while go-webauthn verifies with the COSE key. The Phase 30 import will need a PEM→COSE conversion or must drop WebAuthn rows. PHP's user handle is `\hex2bin(str_pad(dechex($user->id), 16, '0', STR_PAD_LEFT))` (WebAuthnMethod.php:127), the 8-byte big-endian user ID. Using the same handle in Go keeps imported passkeys mappable (Assumption A4).
+
+**Ceremony state storage** (Pitfall 6):
+- Login 2FA: store only the base64url challenge in the challenge row's `code` column (≤255, as PHP does, `method = webauthn`). At verify, rebuild `SessionData{Challenge, RelyingPartyID, UserID, AllowedCredentialIDs (from DB), UserVerification: preferred, Expires}`. The row is server-side, tied to the challenge token and expiring, so it qualifies as "anchored".
+- Registration and passwordless have no challenge row. Store `SessionData` JSON in `cooler` under `mfa:webauthn:::` with a 120 s TTL and consume it with `cooler.Cache.Pull` (atomic, one use) [VERIFIED: summercms.go/modules/cooler/cooler.go:83-87 `Pull … of two concurrent callers at most one gets found=true`]. The client echoes `challenge` (PHP's field), which is used only as the lookup key. As with sociallogin, the memory driver works on one instance only.
+
+**Envelopes (D-08: standard inner shapes, PHP outer keys)**:
+- `register/options` and `webauthn/options` answer `{"options": , "challenge": ""}`. PHP's WebAuthnMethod returned `['options' => …, 'challenge' => …]` (:139-142, :217-220).
+- `register` takes `{attestation: , challenge, name?}`.
+- `webauthn/verify` takes `{challenge_token, assertion: , trust_device?}`.
+- The passwordless pair takes `{}` → `{options, challenge}` and `{challenge, assertion}`.
+- `@simplewebauthn/browser` needs `options.publicKey` as its `optionsJSON` [ASSUMED].
+
+**RP ID and origins** (Pitfall 7):
+- The RP ID must equal, or be a registrable suffix of, the page origin running `navigator.credentials`, which is the SPA, not the API.
+- IP-address hosts are not valid RP IDs in browsers [CITED: lists.w3.org/Archives/Public/public-webauthn/2017May/0250.html; ory.sh/docs/troubleshooting/passkeys-webauthn-security-error].
+- Default `RPID` = `app.url` host (D-04). Add config overrides `golem15.mfa.webauthn.rp_id` and `golem15.mfa.webauthn.origins` (default: the `app.url` origin plus a configured `frontend_url` origin).
+- sm-demo-app's `config/app.yaml` has `url: http://127.0.0.1:8097` [VERIFIED], so WebAuthn there needs `localhost` or a real host.
+
+### Q7. Refresh lineage for D-12b (gap)
+
+[VERIFIED: bouncer/refresh.go:92-155]
+- `refreshAudience` verifies the old token, then `next, _, err := MintAudience(secret, sub, issuerURL, ttl, audience)`, a fresh token with a **new random jti** and `iat = now`. It then `bl.Add(…, jti, expiresAt, time.Now().Add(grace))` for the **old** jti.
+- No claim links the new token to the old one. A device that refreshed once carries a JTI the session row never saw, so blacklisting the stored JTI does nothing.
+
+Fix, all in sm-user-plugin and additive:
+- After `bouncer.Refresh` succeeds in `controllers.Refresh` (api_controller.go:179-213), read the old JTI from `raw` (`jwt.NewParser().ParseUnverified` is safe here because `Refresh` just verified the signature).
+- Read the new JTI from `next` via `bouncer.VerifyClaims`.
+- Run `UPDATE golem15_user_device_auth_sessions SET jwt_jti = ?, last_activity_at = NOW() WHERE jwt_jti = ? AND status = 'used'`.
+
+The only refresh path in any app is this handler. `bouncer.CookieRefreshHandler` exists but no app mounts it [VERIFIED: grep]. With `golem15.user.jwt.blacklist_grace > 0` (default `0`), two concurrent refreshes can fork a lineage, and only one fork is tracked (Pitfall 15).
+
+Revoke then does `bl.Add(ctx, row.JwtJti, now + refreshTTL + jwtTTL + 1 min, now)`, which covers the refresh window as `refreshAudience` does. A password change already kills device tokens through `tokens_valid_after`.
+
+### Q8. Settings screen field types (D-12)
+
+cabana settings forms accept the controller form types minus `widget`, `partial`, `fileupload` and `permissioneditor` [VERIFIED: cabana/settings.go:98-103; form_schema.go:23-30 lists `"text", "textarea", "number", "checkbox", "switch", "dropdown", …, "password", "taglist"`]. There is **no `checkboxlist` and no `radio`**, both of which PHP used (fields.yaml:90-160). Mapping:
+
+| PHP key | PHP type | Go field |
+|---|---|---|
+| `two_factor_mode` | radio | `dropdown` with static options `disabled`, `optional`, `enforced` |
+| `two_factor_available_methods` | checkboxlist | `taglist` `mode: array` on a JSON column, plus `BeforeValidate` allow-list `totp,email,webauthn` (taglist mode array needs an `sql.Scanner`+`driver.Valuer` column, cabana/field_taglist.go:124-139) |
+| `two_factor_enforce_groups` | checkboxlist | `taglist` `mode: array` + `BeforeValidate` against existing group codes |
+| `two_factor_email_code_ttl`, `two_factor_challenge_ttl`, `trusted_device_ttl_days` | number | `number` |
+| `two_factor_passwordless_login`, `trusted_device_enabled`, new social toggle | switch | `switch` |
+
+Follow sm-sociallogin-plugin's settings (singleton row id 1 seeded by the migration, `LoadSettings(ctx, db)` per request, nullable columns where NULL = config default, `Fillable()`, `Rules()`, `BeforeValidate`) [VERIFIED: sm-sociallogin-plugin/models/settings.go, updates/202610200001_create_golem15_sociallogin_settings.go, admin_settings.go].
+
+### Q9. Admin "Reset 2FA" mechanism (D-15)
+
+[VERIFIED: cabana/extension.go:89-107, actions.go:236-252, registry.go:343, pact/capabilities.go:343-376; user plugin controllers/users/config_form.yaml]
+- A record action is offered and routable only if the owner's `config_form.yaml` names it (`recordActionOf` checks `cc.Form.recordActions`). The Users screen has `recordActions: [activate, unban, unsuspend]`.
+- Every YAML name must be registered by the controller, or boot fails with `recordActions: unsupported action %s`.
+- Record-action permissions are validated at boot: `cabana: %s references unknown permission %s`.
+- cabana has no capability through which plugin B adds actions to plugin A's controller.
+
+**Recommended: a user-plugin slot (additive, no framework release).**
+1. The user plugin registers a new permission `golem15.users.reset_two_factor` (no default role, like `manage_privileged_groups`). It must live in the user plugin because a code only mfa registers would fail boot in apps without mfa.
+2. It adds `reset_two_factor` to `recordActions` in `controllers/users/config_form.yaml` and registers the action with `Danger: true`. `Applies` looks up a published `classes.TwoFactorAdmin` service and returns false when none exists (no mfa), so the button never shows there. `Run` delegates to the service inside cabana's transaction (`cabana.TxFromContext`).
+3. mfa publishes the service with `app.Publish[userclasses.TwoFactorAdmin](…)`. It implements `HasTwoFactor(ctx, tx, userID) (bool, error)` and `Reset(ctx, tx, user) error`: disable all methods, delete credentials and recovery codes, delete trusted devices, fire the disabled event.
+
+The rejected alternative is a framework capability (`pact` + cabana) to contribute actions to another plugin's controller. It is generic, but needs a framework tag, docs and a bump of every consumer.
+
+### Q10. Adoption
+
+- **sm-demo-app**: submodule `plugins/golem15/mfa`, `go.work use`, `summer.yaml` entry `- id: golem15.mfa` / `module: git.golem15.com/golem15/sm-mfa-plugin` (after `golem15.user`), `summer build`. `boot_test.go` boots every plugin **without a database**, so mfa's Boot must not need a DB or cache (19.5 Pitfall 8). Configure the WebAuthn RP for local use (Q6).
+- **sm-bm-app**: same mount. The SPA is embedded and same-origin (`config/golem15.sociallogin.yaml:10` says so), so the RP ID default works on a real host. bm's frontend has no 2FA UI; with default `optional` users cannot enable 2FA without one, so login behaviour is unchanged (Open Question 3).
+- **fonoteka.go**: no mfa. Bump the user-plugin pointer and drop `"/devices"` from `banned` in `TestNoDeferredRoutesResolve` [VERIFIED: fonoteka/phase07_coverage_test.go:79-112, list `"pin-login"`, `"verify-pin"`, `"verify-family-member-pin"`, `"/devices"`, `"2fa"`, `"/api/user/batch"`, `"/_user/activate/"`]. Keep `"2fa"` because fonoteka still must not resolve `/2fa`. No other test in fonoteka, demo, bm or the framework references `/devices` or `2fa` [VERIFIED: grep].
+- grzybyfunkcjonalne and summercmsio stay at v0.1.1; nothing forces them.
+
+## Standard Stack
+
+### Core
+| Library | Version | Purpose | Why Standard |
+|---------|---------|---------|--------------|
+| `github.com/pquerna/otp` (`totp`, `hotp`) | v1.5.0 | TOTP generate/validate, otpauth URL | Locked by D-04; Google-Authenticator-compatible defaults (SHA1, 6 digits, 30 s) [VERIFIED] |
+| `github.com/go-webauthn/webauthn` (`webauthn`, `protocol`) | v0.18.2 | WebAuthn registration, assertion, passkeys | Locked by D-04; standard JSON shapes (D-08) [VERIFIED] |
+| `github.com/boombuler/barcode` (`qr`) | v1.1.0 | QR matrix for the TOTP URL | Already required by pquerna/otp; zero new modules (§Q2) [VERIFIED] |
+| `bouncer.HashPassword` / `CheckPassword` (bcrypt) | framework | Recovery and email code hashes | Matches the user plugin (D-06, §Q3) [VERIFIED] |
+| `lagoon.Encrypted` | framework | TOTP secret at rest | D-06 [VERIFIED: lagoon/encrypted.go:32-103] |
+| `cooler.Cache.Pull` | framework ≥ v0.3.1 | One-time WebAuthn ceremony state | Atomic consume (§Q6) [VERIFIED] |
+| stdlib `crypto/rand`, `crypto/sha256`, `crypto/subtle`, `encoding/base64` | Go 1.27 | Tokens, hashes, constant-time compares | Standard library first |
+
+### Supporting
+| Library | Version | Purpose | When to Use |
+|---------|---------|---------|-------------|
+| `postcard.Mailer` | framework | Email code mail | `app.Lookup[postcard.Mailer]()` as the user plugin does |
+| `lagoon.Validate` | framework | Laravel-style rules (`size`, `in`, `min`, `max` are supported) | Request validation with PHP error shape |
+| `wire.Time` | framework | `+00:00` timestamps | `expires_at`, credential dates |
+| testcontainers Postgres | v0.44.0 | DB tests | Copy sociallogin `testmain_test.go` / `harness_test.go` |
+
+### Alternatives Considered
+| Instead of | Could Use | Tradeoff |
+|------------|-----------|----------|
+| boombuler + own SVG writer | `piglig/go-qr` v1.1.0 (`ToSVGBytes`) | Native SVG, but a new 67-star single-maintainer module |
+| bcrypt | argon2id (`x/crypto/argon2`) | Not what the user plugin uses; D-06 asks to match |
+| `credential_data` JSON column | explicit columns per `Credential` field | The library doc prefers explicit columns; JSON survives library field additions with less migration work |
+| user-plugin action slot (D-15) | framework cross-plugin action capability | Generic, but needs a framework release |
+
+**Installation** (inside `sm-mfa-plugin`, only after the decision note exists and the human package check passes):
+```bash
+go get github.com/pquerna/otp@v1.5.0 github.com/go-webauthn/webauthn@v0.18.2 github.com/boombuler/barcode@v1.1.0
+go mod tidy
+```
+
+## Package Legitimacy Audit
+
+The `package-legitimacy` seam supports only npm, PyPI and crates (`Usage: msd-tools package-legitimacy check --ecosystem `), so it cannot check Go modules [VERIFIED: ran it this session]. The 19.4 and 19.5 precedent applies: Go modules are [ASSUMED] for legitimacy, and the planner gates the first `go get` behind a `checkpoint:human-verify` task. Signals gathered by hand:
+
+| Package | Registry | Age | Popularity | Source Repo | Verdict | Disposition |
+|---------|----------|-----|-----------|-------------|---------|-------------|
+| `github.com/pquerna/otp` | proxy.golang.org | v1.5.0 2024-12-31, repo ~10 yrs [ASSUMED age] | 2,963 stars | github.com/pquerna/otp (Apache-2.0) | n/a (seam unsupported) | Approved pending human check |
+| `github.com/go-webauthn/webauthn` | proxy.golang.org | v0.18.2 2026-09-19 | 1,351 stars, pushed 2026-10-20 | github.com/go-webauthn/webauthn (BSD-3) | n/a | Approved pending human check |
+| `github.com/boombuler/barcode` | proxy.golang.org | v1.1.0 2025-07-22 | 1,563 stars | github.com/boombuler/barcode (MIT) | n/a | Approved pending human check (already transitive via otp) |
+| `github.com/piglig/go-qr` | proxy.golang.org | v1.1.0 2026-05-29 | 67 stars | github.com/piglig/go-qr (MIT) | n/a | Alternative only, not recommended |
+
+**Packages removed due to [SLOP] verdict:** none.
+**Packages flagged as suspicious [SUS]:** none. All three recommended modules are [ASSUMED] for legitimacy (the seam cannot run on Go), so the planner adds one `checkpoint:human-verify` before the first `go get`.
+
+## Architecture Patterns
+
+### System Architecture Diagram
+
+```
+ SPA (password login) SPA / new device
+ │ POST /_user/api/v1/login │ POST devices/initiate ──► row(pending, token#, short_code, 5 min)
+ ▼ │ GET devices/status/{token} (poll 2 s)
+ user plugin Login │ │ pending / expired / used / revoked
+ lookup → ban check → bcrypt → CheckAndRecordLogin │ │ confirmed ─► atomic confirmed→used ─► mint JWT (jti→row) ─► {data:{status:confirmed,user_id,token,user}}
+ │ │
+ ▼ signed-in phone ─► POST devices/authorize {short_code|token} (JWT)
+ LoginGates.Check(attempt{user, source, fields}) └► atomic pending→confirmed (user_id, auth_ip)
+ │ no gate / nil │ challenge user: GET devices / DELETE devices/{id} ─► blacklist row.jwt_jti
+ ▼ ▼
+ mint JWT → {token,user} 200 {two_factor_required, challenge_token, available_methods, expires_at}
+ │
+ mfa challenge routes (throttle:2fa-verify, 10/min/IP)
+ ┌───────────────┬───────────────┬──────────────────┬──────────────────────┐
+ │ verify totp │ email/send → │ recovery │ webauthn/options → │
+ │ (replay-safe) │ mail code# │ (bcrypt, consume)│ code=challenge; │
+ │ │ verify email │ │ webauthn/verify │
+ └───────┬───────┴───────┬───────┴────────┬─────────┴──────────┬───────────┘
+ └─ atomic attempts++ (guarded) ──┴─ verify ─ atomic completed_at ─► FinishLogin + IssueToken + LoginEvent
+ (+ trusted_device_token if asked & enabled)
+ sociallogin callback → signIn → LoginGates.Check(source=social) → completion code carries {token,…} or {challenge}
+ refresh → bouncer.Refresh → device row jwt_jti old→new
+ password change/reset/admin set → PasswordChangedEvent → mfa deletes trusted devices
+ admin Users preview → reset_two_factor (slot) → TwoFactorAdmin.Reset (mfa)
+```
+
+### Recommended Project Structure
+```
+sm-mfa-plugin/ # git.golem15.com/golem15/sm-mfa-plugin, plugin golem15.mfa
+├── plugin.go # ID, Requires golem15.user, Register/Boot (publish gate + TwoFactorAdmin), Buckets "2fa-verify"
+├── routes.go # /_user/api/v1/2fa challenge group (throttle:2fa-verify) + management group (jwt.auth)
+├── admin_settings.go, admin_permissions.go
+├── config.go, config/config.yaml # defaults: mode optional, methods [totp,email], ttl, rp overrides, issuer
+├── classes/ # gate, challenge service, totp, email, recovery, trusted, webauthn wrapper, qr svg, events
+├── controllers/ # challenge.go, management.go, passwordless.go
+├── models/ # TwoFactorMethod, TwoFactorChallenge, TwoFactorRecoveryCode, TrustedDevice, WebAuthnCredential, Settings
+├── models/settings/fields.yaml
+├── updates/ # 5 table migrations + settings migration
+├── views/mail/two_factor_code.htm, two_factor_code-en.htm
+├── lang/{en,pl}/lang.yaml
+├── go.mod, go.work # replace framework ../summercms.go, user plugin ../fonoteka.go/plugins/golem15/user
+└── *_test.go # testmain + harness copied from sm-sociallogin-plugin
+
+sm-user-plugin (additions)
+├── classes/login_gate.go # LoginGate, LoginAttempt, LoginGates registry
+├── classes/events.go # + LoginEvent, PasswordChangedEvent, TwoFactorAdmin interface
+├── controllers/devices.go # initiate, status, authorize, list, revoke
+├── models/device_auth_session.go
+├── updates/2026102X0001_create_device_auth_sessions.go
+└── controllers/users/config_form.yaml # + reset_two_factor
+```
+
+### Pattern 1: Login-gate registry (D-02)
+**What:** The user plugin publishes a `*classes.LoginGates` registry in `Register`, and mfa adds its gate in `Boot`, since party runs every Register before every Boot in `Requires` order. `Login` calls `gates.Check(ctx, attempt)` after `CheckAndRecordLogin` succeeds. A non-nil challenge writes 200 JSON and stops. An error writes the opaque 500 and **never** falls through to minting. No registry (unit tests that skip Register) behaves as no gate.
+**When:** password `Login`, sociallogin `signIn`. Not for refresh, register, activate-by-code or link.
+**Example:** Code Example 1.
+
+### Pattern 2: Every state change is one guarded UPDATE
+**What:** Increment attempts with `UPDATE … SET attempts = attempts + 1 WHERE id = ? AND completed_at IS NULL AND expires_at > NOW() AND attempts < max_attempts`. Treat `RowsAffected == 0` as exhausted or invalid, and verify only after a successful increment. Complete with `… SET completed_at = NOW() WHERE id = ? AND completed_at IS NULL` (one token per challenge). Consume a recovery code with `… SET used_at = NOW() WHERE id = ? AND used_at IS NULL`. Confirm a device with `… WHERE status = 'pending' AND user_id IS NULL AND expires_at > NOW()`. Hand over the device token with `… SET status = 'used' WHERE status = 'confirmed'`.
+**Why:** PHP does read-modify-write (`$this->attempts++; save()`), so N parallel verifies each see the old counter, and two verifies can both complete and mint.
+
+### Pattern 3: Ceremony state server-side, client echo only as key
+**What:** Covered in §Q6. A login-2FA challenge goes in `code`. Registration and passwordless state go in `cooler` + `Pull`. Never accept a client-supplied challenge as the expected value (PHP `webauthnRegister` did).
+
+### Pattern 4: Device session state machine
+`pending → confirmed` (authorize), `confirmed → used` (first poll, JWT minted, jti stored), `used → revoked` (DELETE, jti blacklisted), and `pending|confirmed → expired` (lookup after `expires_at`). Never move `used` or `revoked` to `expired`; PHP's `deviceAuthStatus` checks `isExpired()` before the status and would. The device list shows `confirmed|used`, `is_current` is `row.jwt_jti == bearer jti`, and `last_activity_at` advances on refresh.
+
+### Pattern 5: Security events as typed festival events
+`PasswordChangedEvent{User, Tx, Source}` fires from reset, change and admin set. Fire it inside the same transaction as the password write and let a listener error roll the write back, as `DeactivatingEvent` does. mfa listens and deletes trusted devices. mfa's own events are typed structs with `…EventName` constants holding the PHP names (`golem15.user.2fa.challenged`, …), mirroring `CreatedEventName = "user.Created"`.
+
+### Anti-Patterns to Avoid
+- **Gate error ⇒ login anyway.** Fail closed (500).
+- **Reading the login body twice.** `Login` already consumed it; pass `fields` in the attempt (Pitfall 2).
+- **Filtering `available_methods` by the global list.** PHP doesn't, and it could lock users out.
+- **One `Where` for several `{id}` routes.** surf attaches it to the last route only.
+- **Logging `RetrieveError`-style library detail or any token/code.** Log types, never values.
+
+## Don't Hand-Roll
+
+| Problem | Don't Build | Use Instead | Why |
+|---------|-------------|-------------|-----|
+| TOTP (HOTP truncation, base32, skew) | own HMAC/truncation | `pquerna/otp/totp`, `hotp.ValidateCustom` per counter | RFC 6238 edge cases, constant-time compare inside |
+| WebAuthn (CBOR, COSE, attestation, flags, counters) | own parser | `go-webauthn` | Huge spec surface; BE/BS/UV rules, origin/top-origin checks |
+| QR encoding (ECC, masks) | own encoder | `boombuler/barcode/qr` | Only the SVG serialization is ours |
+| Slow hash | own KDF | `bouncer.HashPassword` | Same as passwords |
+| One-time ceremony state | ad-hoc map | `cooler.Cache.Pull` | Atomic, TTL, Redis-capable |
+| Token hashing | — | `sha256` hex (`classes.HashAPIToken` in the user plugin) | Same scheme as API tokens (D-06) |
+| Rate limiting | own counters for IP limits | surf bucket `2fa-verify` | Framework limiter |
+
+**Key insight:** the security of this phase lives in state transitions (attempt counters, one-time use, revocation lineage), not in crypto primitives. Libraries cover the crypto; the plan must make every transition a single guarded SQL statement.
+
+## Common Pitfalls
+
+### Pitfall 1: The password step resets the throttle before the second factor
+**What goes wrong:** `CheckAndRecordLogin(…, true)` zeroes `attempts` and clears suspension, and every `/login` makes a fresh challenge with 5 attempts. An attacker holding the password brute-forces TOTP at the `2fa-verify` rate per IP, without limit across IPs. Three of 10⁶ codes are valid per window.
+**How to avoid:** add a per-user second-factor failure cap in mfa (for example ≥10 failed verifications in 15 min → refuse new challenges and verifies with the generic 401 until the window passes; counted from challenge rows in the DB, so it is multi-instance safe). Keep failed challenge rows for the window instead of PHP's eager `cleanupExpired`. Values are [ASSUMED] (A6).
+**Warning signs:** a test that loops login→verify(wrong) never gets refused.
+
+### Pitfall 2: Trusted-device token must come from the already-read body
+`Login` calls `readFields(r)` first. The gate needs `trusted_device_token` from those `fields`, so `LoginAttempt` carries them. The social path (top-level redirect) has no body, so trusted devices never skip the social challenge (A7).
+
+### Pitfall 3: A bcrypt rehash is not a password change
+`Login` rewrites `password` when `NeedsRehash` (api_controller.go:106-109). It must not fire `PasswordChangedEvent`, or every cost bump would wipe trusted devices.
+
+### Pitfall 4: PHP's attempts are off by one
+PHP increments, then refuses when `attempts >= max_attempts`, so the 5th call never verifies: four real tries. The guarded UPDATE in Pattern 2 gives five. The response shape is identical either way; recommend five and record the deviation (A5).
+
+### Pitfall 5: TOTP code replay
+pquerna's `Validate` accepts the same code again within the ±1 window. Validate per counter (`counter-1, counter, counter+1` with `hotp.ValidateCustom`), store the accepted counter in `golem15_user_two_factor_methods.metadata` (`{"last_counter": N}`), and refuse `counter <= last_counter`. This needs no schema change.
+
+### Pitfall 6: Trusting the client's WebAuthn challenge
+PHP `webauthnRegister` builds the expected challenge from `$request->get('challenge')`. Go must load `SessionData` server-side (Pattern 3). go-webauthn's `SessionData.Extensions` encoding changes across releases, so cached sessions are drained on upgrade. With a 120 s TTL that happens on its own.
+
+### Pitfall 7: RP ID vs SPA origin; IP hosts
+See §Q6. Symptom: `SecurityError` in the browser on `create()`/`get()`. Configure `rp_id`/`origins` per app.
+
+### Pitfall 8: Stored credential flags
+Without persisting BE/BS/UV, `ValidateLogin` fails after the first login, or BE-change detection is lost. Write the updated `Credential` back after every successful assertion (sign count, flags, clone warning).
+
+### Pitfall 9: bcrypt CPU per recovery attempt
+Up to 10 compares (~0.63 s) per wrong recovery code. Keep the bucket, normalize input (uppercase, strip spaces, length cap) before hashing, and stop at the first match.
+
+### Pitfall 10: Email method downgrade and mail bombing
+PHP lets any challenge request an email code even when the user enabled only TOTP, and `email/send` is unlimited within the bucket. Recommend: `email/send` and `verify method=email` only when the user's email method is enabled (otherwise the same 401 `{"error":"Invalid or expired challenge."}`), and at most 3 sends per challenge. This is hardening beyond PHP (A8).
+
+### Pitfall 11: Email code lifetime vs mail text
+PHP mails `expiry_minutes = two_factor_email_code_ttl` (10), but the code dies with the challenge (5). Either cap the mail value at the remaining challenge TTL, or store a code expiry. Recommend `min(email_ttl, remaining challenge time)` in the mail (A9).
+
+### Pitfall 12: Initial recovery codes are never shown in PHP
+See Open Question 1. If the Go port adds `recovery_codes` to the TOTP-confirm/email-enable/register response on first creation, that is an additive key.
+
+### Pitfall 13: `used` sessions downgraded to `expired`
+PHP `deviceAuthStatus` marks any session past `expires_at` as expired, including `used` ones, which removes active devices from the list and breaks revoke. Expire only `pending|confirmed`.
+
+### Pitfall 14: `device_name` is attacker-controlled and unbounded
+`initiate` is unauthenticated, and the column is VARCHAR(255). Validate `nullable|string|max:255` (or truncate), and have the frontend show `device_ip` and the parsed UA next to the name (D-12c phishing mitigation).
+
+### Pitfall 15: Refresh forks with `blacklist_grace`
+Covered in §Q7. With grace > 0, two refreshes of the same token both succeed, and only one new JTI is tracked. Document it; the default grace is `0`.
+
+### Pitfall 16: Pending-session table growth
+Unauthenticated `initiate` inserts rows at up to 120/min/IP. Delete stale `pending|expired` rows older than an hour on each initiate (bounded `DELETE`), or sweep them.
+
+### Pitfall 17: surf `Where` binds to the last route
+`g.Delete("/devices/{id}", …); g.Where("id", "[0-9]+")` must be adjacent. Likewise for `webauthn/{id}` and `trusted-devices/{id}` in mfa.
+
+### Pitfall 18: postcard ownership and layouts
+Covered in §Q5. mfa's templates must be `golem15.mfa::mail.*`, and the `user` layout alias is not visible to mfa.
+
+### Pitfall 19: Settings type gaps
+Covered in §Q8. Don't write `checkboxlist`/`radio` in fields.yaml; cabana boot fails on unknown types.
+
+### Pitfall 20: Boot without DB in sm-demo-app
+`boot_test.go` boots all plugins without a database. mfa Boot only reads config, publishes the gate, the TwoFactorAdmin service and the event listeners, and looks up DB and cache per request.
+
+### Pitfall 21: Soft-deleted account restored by the password step
+`Login` undeletes a soft-deleted account and mails "reactivate" (:87-93) before the gate, so a password-only attacker can restore a deactivated account without passing 2FA. D-02 locks the gate placement after this step. Note it in the security review, or move the undelete after the gate if the user agrees (A10).
+
+## Code Examples
+
+### 1. Login-gate registry and wiring (identifiers proposed, Claude's discretion)
+```go
+// sm-user-plugin/classes/login_gate.go
+type LoginAttempt struct {
+ User *models.User
+ Source string // "password" or "social"
+ Fields map[string]any // parsed body (password login), carries trusted_device_token
+ Request *http.Request
+}
+
+type LoginGate interface {
+ // Challenge returns nil to allow the sign-in, or the JSON body that
+ // replaces the token response (HTTP 200).
+ Challenge(ctx context.Context, a LoginAttempt) (map[string]any, error)
+}
+
+type LoginGates struct {
+ mu sync.RWMutex
+ gates []LoginGate
+}
+
+func (g *LoginGates) Add(gate LoginGate) { g.mu.Lock(); g.gates = append(g.gates, gate); g.mu.Unlock() }
+
+func (g *LoginGates) Check(ctx context.Context, a LoginAttempt) (map[string]any, error) {
+ if g == nil {
+ return nil, nil
+ }
+ g.mu.RLock()
+ gates := slices.Clone(g.gates)
+ g.mu.RUnlock()
+ for _, gate := range gates {
+ body, err := gate.Challenge(ctx, a)
+ if err != nil || body != nil {
+ return body, err // fail closed on error
+ }
+ }
+ return nil, nil
+}
+
+// controllers/api_controller.go Login, after CheckAndRecordLogin(…, true) succeeds, before mintFor:
+if gates, ok := app.Lookup[*classes.LoginGates](); ok {
+ body, err := gates.Check(ctx, classes.LoginAttempt{User: user, Source: "password", Fields: fields, Request: r})
+ if err != nil {
+ writeOpaque500(w)
+ return
+ }
+ if body != nil {
+ writeJSON(w, http.StatusOK, body)
+ return
+ }
+}
+```
+
+### 2. Guarded attempt increment (Pattern 2)
+```go
+res := tx.Exec(`UPDATE golem15_user_two_factor_challenges
+ SET attempts = attempts + 1, updated_at = NOW()
+ WHERE token = ? AND completed_at IS NULL AND expires_at > NOW() AND attempts < max_attempts`,
+ sha256Hex(challengeToken))
+if res.Error != nil { return errServer }
+if res.RowsAffected == 0 { return errInvalidChallenge } // 401 {"error":"Invalid verification code.","two_factor_failed":true}
+// … verify the factor …
+res = tx.Exec(`UPDATE golem15_user_two_factor_challenges SET completed_at = NOW()
+ WHERE id = ? AND completed_at IS NULL`, ch.ID)
+if res.RowsAffected != 1 { return errInvalidChallenge } // lost a race: never mint twice
+```
+
+### 3. TOTP verify with replay protection
+```go
+// Source: pquerna/otp@v1.5.0 totp/totp.go:101-133 (ValidateCustom loops counters with hotp.ValidateCustom)
+opts := hotp.ValidateOpts{Digits: otp.DigitsSix, Algorithm: otp.AlgorithmSHA1}
+now := uint64(time.Now().Unix() / 30)
+for _, c := range []uint64{now - 1, now, now + 1} {
+ ok, err := hotp.ValidateCustom(code, c, secret, opts)
+ if err != nil { return false, err }
+ if ok {
+ if c <= meta.LastCounter { return false, nil } // replay
+ meta.LastCounter = c // persist in metadata JSON, same tx as completion
+ return true, nil
+ }
+}
+return false, nil
+```
+
+### 4. QR as SVG (verified round-trip this session)
+```go
+// Source: scratch probe; boombuler/barcode v1.1.0 qr.Encode
+func qrSVG(text string, size int) (string, error) {
+ code, err := qr.Encode(text, qr.M, qr.Auto)
+ if err != nil { return "", err }
+ b, n, q := code.Bounds(), code.Bounds().Dx(), 4
+ var p strings.Builder
+ for y := 0; y < n; y++ {
+ for x := 0; x < n; x++ {
+ if r, _, _, _ := code.At(b.Min.X+x, b.Min.Y+y).RGBA(); r == 0 {
+ fmt.Fprintf(&p, "M%d %dh1v1h-1z", x+q, y+q)
+ }
+ }
+ }
+ w := n + 2*q
+ return fmt.Sprintf(``,
+ size, size, w, w, p.String()), nil
+}
+```
+
+### 5. WebAuthn instance per request (settings and config change without restart)
+```go
+w, err := webauthn.New(&webauthn.Config{
+ RPID: rpID, // golem15.mfa.webauthn.rp_id, default app.url host
+ RPDisplayName: appName, // app.name
+ RPOrigins: origins, // app.url origin + frontend origin(s)
+ AttestationPreference: protocol.PreferNoAttestation,
+ Timeouts: webauthn.TimeoutsConfig{
+ Login: webauthn.TimeoutConfig{Enforce: true, Timeout: 60 * time.Second},
+ Registration: webauthn.TimeoutConfig{Enforce: true, Timeout: 60 * time.Second},
+ },
+})
+```
+
+### 6. Passwordless verify (D-14)
+```go
+// Source: go-webauthn@v0.18.2 webauthn/login.go:276 ValidatePasskeyLogin
+raw, found, err := cache.Pull(ctx, "mfa:webauthn:passwordless:-:"+challenge)
+if err != nil || !found { /* 401 generic */ }
+var sess webauthn.SessionData // created by BeginDiscoverableLogin(webauthn.WithUserVerification(protocol.VerificationRequired))
+_ = json.Unmarshal(raw, &sess)
+parsed, err := protocol.ParseCredentialRequestResponseBytes(assertionJSON)
+user, cred, err := w.ValidatePasskeyLogin(func(rawID, handle []byte) (webauthn.User, error) {
+ row, err := credentialByRawID(ctx, db, rawID) // unique index on credential_id
+ if err != nil || row == nil || !row.IsEnabled || !row.Passwordless { return nil, errNotPasskey }
+ u, err := loadUser(ctx, db, row.UserID)
+ if err != nil || !bytes.Equal(handle, u.WebAuthnID()) { return nil, errNotPasskey }
+ return u, nil
+}, sess, parsed)
+// then: not soft-deleted, activated, RejectIfThrottled; write back cred; FinishLogin; IssueToken; LoginEvent
+```
+
+### 7. Device poll hand-over (D-12a/b)
+```go
+token, jti, err := bouncer.Mint(secret, strconv.FormatUint(uint64(row.UserID), 10), issuer, ttl)
+if err != nil { /* 500 */ }
+res := db.Exec(`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()`, jti, row.ID)
+if res.RowsAffected != 1 {
+ // another poll won: answer the current status without a token; the minted token was never sent
+}
+// 200 {"success":true,"data":{"status":"confirmed","user_id":…,"message":"Device authorized - complete login","token":…,"user":…}}
+```
+
+### 8. Refresh hands the JTI forward (§Q7)
+```go
+next, err := bouncer.Refresh(secret, raw, refreshTTL(app), bl, blacklistGrace(app), requestURL(r))
+if err == nil {
+ oldJTI := unverifiedJTI(raw) // signature already checked by Refresh
+ if _, _, _, newJTI, err := bouncer.VerifyClaims(next, secret); err == nil && oldJTI != "" {
+ _ = gdb.Exec(`UPDATE golem15_user_device_auth_sessions SET jwt_jti = ?, last_activity_at = NOW()
+ WHERE jwt_jti = ? AND status = 'used'`, newJTI, oldJTI).Error // log on error, never fail the refresh
+ }
+}
+```
+
+## State of the Art
+
+| Old Approach | Current Approach | When Changed | Impact |
+|--------------|------------------|--------------|--------|
+| lbuchs plain-base64 options, PEM keys | WebAuthn L3 JSON (base64url), COSE keys, BE/BS flags | WebAuthn L3; go-webauthn v0.11+ BE checks [ASSUMED version] | D-08 deviation; PHP credentials not importable as-is |
+| Second-factor-only security keys | Discoverable passkeys with UV | Industry 2023-2025 [ASSUMED] | D-14 passwordless with `required` RK + UV |
+| Cookie-based remember-device | Opaque bearer token returned to SPA | PHP already had `createTrustedDeviceToken` | D-10 |
+
+**Deprecated/outdated:** go-webauthn stored the attestation *format* in `AttestationType` in older releases. `Credential.UnmarshalJSON` migrates such records (credential.go:189-212). Write `AttestationFormat` explicitly.
+
+## Assumptions Log
+
+| # | Claim | Section | Risk if Wrong |
+|---|-------|---------|---------------|
+| A1 | boombuler + own 25-line SVG writer satisfies D-04's "library that can emit SVG" | §Q2 | User wanted native SVG output: swap to piglig/go-qr (one new module) |
+| A2 | Carbon serializes raw dates as `…T…:…:….000000Z` in `status.trusted_devices` | §Q4 | Byte-parity test pins the wrong format |
+| A3 | `credential_data` JSON column instead of explicit per-field columns | §Q6 | Planner prefers explicit columns: more migration columns, same behaviour |
+| A4 | PHP-compatible 8-byte big-endian user ID as WebAuthn user handle | §Q6 | Reveals the numeric user ID to authenticators; random handles need a lookup column |
+| A5 | Five real verification attempts (PHP gives four) | Pitfall 4 | Minor UX parity difference |
+| A6 | Per-user 2FA failure cap (10 per 15 min) | Pitfall 1 | Without it, password-holders can brute-force TOTP across IPs |
+| A7 | Social sign-in through the challenge ignores trusted devices | Pitfall 2 | Users with trusted devices still get challenged on social login when the toggle is on |
+| A8 | Email code only for users who enabled email; ≤3 sends per challenge | Pitfall 10 | Diverges from PHP's permissive fallback |
+| A9 | Mail `expiry_minutes` capped at the remaining challenge time | Pitfall 11 | Mail text says 10 min while the code dies at 5 |
+| A10 | Undelete-before-2FA left as is (D-02 placement) | Pitfall 21 | A password-only attacker can restore a deactivated account |
+| A11 | Registration and passwordless ceremony state in `cooler` (memory driver single-instance) | §Q6 | Multi-instance apps without Redis fail ceremonies spanning instances |
+| 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
+
+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.
+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.
+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.
+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).
+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
+
+| Dependency | Required By | Available | Version | Fallback |
+|------------|------------|-----------|---------|----------|
+| Go | all | ✓ | go1.27.2 | — |
+| Docker | testcontainers Postgres tests | ✓ | 29.9.1 (also via `sudo -u golem`) | `-short` runs skip them |
+| Postgres (local) | dev only | ✓ | accepting on /run/postgresql:5432 | testcontainers |
+| Redis (local) | cooler Redis tests | ✓ | PONG | memory driver / miniredis |
+| Node / npm | bm frontend tests (if touched) | ✓ | v22.23.3 / 12.2.1 | — |
+| proxy.golang.org | `go get` | ✓ | — | — |
+| `govulncheck` | dependency audit | via `go run golang.org/x/vuln/cmd/govulncheck@v1.9.0` | v1.9.0 | — |
+| A WebAuthn authenticator + browser on a non-IP origin | WebAuthn UAT | ✗ in CI | — | Unit tests with go-webauthn virtual-authenticator data; human UAT |
+
+**Missing dependencies with no fallback:** none for automated work. A real browser ceremony is human UAT.
+
+## Validation Architecture
+
+### Test Framework
+| Property | Value |
+|----------|-------|
+| Framework | Go `testing` (go1.27.2) + `httptest`; testcontainers-go v0.44.0 Postgres (DB tests skip under `-short`) |
+| Config file | none. New `sm-mfa-plugin/testmain_test.go` + `harness_test.go` copied from `sm-sociallogin-plugin` (`bootSocialWith` → boots `golem15.user` + plugin, memory cooler) |
+| Quick run command | `cd sm-mfa-plugin && go test -short ./...`; `cd fonoteka.go/plugins/golem15/user && go test -short ./...` |
+| Full suite command | `cd fonoteka.go/plugins/golem15/user && go vet ./... && go test ./...`; `cd sm-mfa-plugin && go vet ./... && go test ./...`; `cd sm-sociallogin-plugin && go test ./...`; `cd sm-demo-app && go test ./...`; `cd sm-bm-app && go test ./...`; `cd fonoteka.go && go test ./...` |
+
+### Phase Decisions → Test Map
+| Item | Behavior | Test Type | Automated Command | File Exists? |
+|------|----------|-----------|-------------------|-------------|
+| D-02 | no gate → login unchanged; gate challenge → 200 shape, no token; gate error → 500, no token | unit (user plugin) | `go test ./... -run TestLoginGate` | ❌ Wave 0 |
+| D-02/SC-2 | 2FA user login → `{two_factor_required, challenge_token(64), available_methods, expires_at +00:00}`; `2fa-verify` 11th req/min → 429 | integration | mfa `-run 'TestLoginChallenge\|TestBucket2faVerify'` | ❌ |
+| D-05 | verify/recovery/email/send/webauthn verify: every PHP error body verbatim; attempts 5 then refused; parallel verifies → one token | integration | mfa `-run 'TestChallenge'` | ❌ |
+| D-05 | TOTP setup → `{secret, qr_code_svg, qr_code_url}` (SVG decodes to URL); confirm/disable; replay refused | unit + integration | mfa `-run 'TestTOTP\|TestQRSVG'` | ❌ |
+| D-05 | recovery: 10 codes, format/alphabet regex, hashed, consume once (concurrent), regenerate invalidates, remaining count | integration | mfa `-run TestRecovery` | ❌ |
+| D-05 | email: 6 digits hashed, template `golem15.mfa::mail.two_factor_code[-en]` vars, masked email | integration (mail capture) | mfa `-run TestEmailCode` | ❌ |
+| D-06 | challenge/trusted/device tokens stored as 64-hex sha256; TOTP secret ciphertext ≠ secret | integration | `-run TestTokensAtRest` (mfa + user plugin) | ❌ |
+| D-07 | change, reset, admin set → trusted devices gone; rehash does not; last method disabled → gone | integration | user plugin `-run TestPasswordChangedEvent`; mfa `-run TestRevocation` | ❌ |
+| D-08/D-14 | WebAuthn registration + assertion with recorded authenticator fixtures; BE flag persisted; passwordless refuses second-factor-only and UV-less credentials; setting off → refused | unit + integration | mfa `-run 'TestWebAuthn\|TestPasswordless'` | ❌ |
+| D-09 | toggle off → `oauth-complete` unchanged; on → challenge shape, no token; link not gated | integration | sociallogin `-run TestSocialGate` | ❌ |
+| D-10 | `trust_device` + enabled → `trusted_device_token`; login with it skips challenge; expired/other user's token → challenge | integration | mfa `-run TestTrustedDevice` | ❌ |
+| D-11/D-12a | initiate/status/authorize/list/revoke PHP shapes (flat expired body); first poll gets token once (concurrent polls); used never becomes expired | integration | user plugin `-run TestDevice` | ❌ |
+| D-12b | revoke → device JWT 401; after a refresh the new token is also 401 | integration | user plugin `-run TestDeviceRevokeAfterRefresh` | ❌ |
+| D-12 | settings row overrides config on next request; taglist allow-list | integration | mfa `-run TestSettings` | ❌ |
+| D-13 | mode disabled → no challenge even with methods | integration | mfa `-run TestModeDisabled` | ❌ |
+| D-15 | reset action absent without mfa (Applies false); with mfa resets all and fires disabled; permission required | integration (admin harness) | user plugin `-run TestResetTwoFactorSlot`; mfa `-run TestAdminReset` | ❌ |
+| D-16 | migrations up/down; columns and indexes as PHP + additions | integration | `-run TestMigrations` | ❌ |
+| D-17 | demo boots with mfa (no DB); fonoteka guard passes with `/devices`, still bans `2fa` | app tests | `cd sm-demo-app && go test -run TestBootAllPlugins`; `cd fonoteka.go && go test ./plugins/golem15/fonoteka -run TestNoDeferredRoutesResolve` | ⚠ update existing |
+| PHP map | `tests/security/AuthenticationTest.php:149` (USER-005 settings type safety) → typed mode setting test | unit | mfa `-run TestPHPUser005` | ❌ |
+
+### Sampling Rate
+- **Per task commit:** `go vet ./...` + `go test -short ./...` of the touched repo.
+- **Per plan merge:** full suites (Docker) of every touched repo.
+- **Phase gate:** a `scripts/check-phase19.6.sh` in the 19.5 style. It runs the stages go/userplugin (with an additive-exports check against `v0.1.5` and a migrations-only-added check)/mfa (no `gopkg.in/yaml.v3` in `go list -deps .`)/sociallogin/demo/bm/fonoteka/docker/docs (decision note names the three pins). It must pass before `/msd-verify-work`. Real-browser WebAuthn is human UAT.
+
+### Wave 0 Gaps
+- [ ] `sm-mfa-plugin/testmain_test.go`, `harness_test.go` (copy of sociallogin's, booting `golem15.user` + `golem15.mfa`)
+- [ ] WebAuthn fixtures: recorded attestation/assertion JSON for a known RP ID/origin (go-webauthn's own test vectors or a virtual authenticator in the test)
+- [ ] Clock injection for TOTP tests (pass `time.Time` into the verifier)
+- [ ] user-plugin harness helper for device-flow tests (existing `session_test.go` `insertUser`/`loginToken`)
+
+## Security Domain
+
+`security_enforcement` is absent from `.planning/config.json`, so it is treated as enabled. Target ASVS L1 plus the explicit D-14 review.
+
+### Applicable ASVS Categories
+
+| ASVS Category | Applies | Standard Control |
+|---------------|---------|-----------------|
+| V2 Authentication | yes | TOTP (RFC 6238 via pquerna, replay-safe), WebAuthn (go-webauthn, UV rules), bcrypt-hashed recovery and email codes, per-IP bucket + per-user failure cap, guarded attempt counter |
+| V3 Session Management | yes | Challenge token 64 chars, SHA-256 at rest, 5 min, one completion. Device JWT one-time hand-over, JTI tracked through refresh, blacklist on revoke. `tokens_valid_after` on password change |
+| V4 Access Control | yes | Management routes `jwt.auth`. Every row query scoped by `user_id`. Admin reset behind `golem15.users.reset_two_factor` |
+| V5 Input Validation | yes | `lagoon.Validate` with PHP rules. Base32 check on the TOTP `secret`. Length caps (`device_name`, recovery input, WebAuthn JSON body size) |
+| V6 Cryptography | yes | Libraries only. `crypto/rand` tokens. `subtle.ConstantTimeCompare` where comparing secrets. `lagoon.Encrypted` for TOTP secret |
+| V7 Errors and Logging | yes | Generic 401s. Never log codes, tokens, secrets or assertions. `Registration failed:` with a fixed reason, not library internals. The access log never logs paths (surf/accesslog.go:51-58), but reverse proxies may log `devices/status/{token}` |
+
+### Known Threat Patterns
+
+| Pattern | STRIDE | Standard Mitigation |
+|---------|--------|---------------------|
+| TOTP brute force across challenges (Pitfall 1) | Spoofing | `2fa-verify` 10/min/IP + per-user failure cap |
+| Parallel verify races (attempt bypass, double mint) | Tampering | Guarded UPDATEs (Pattern 2) |
+| TOTP replay within window | Spoofing | Last-counter tracking (Pitfall 5) |
+| WebAuthn challenge supplied by client | Spoofing | Server-side `SessionData` (Pattern 3) |
+| Passkey login with a second-factor-only credential | Elevation | `passwordless` column + UV required + discoverable only (D-14) |
+| Credential cloning | Spoofing | Sign-count / `CloneWarning` persisted; reject or flag |
+| Device-code phishing (attacker sends victim a QR to authorize) | Spoofing | D-12c accepted; frontend shows device name, IP and parsed UA. Short TTL. Revoke list |
+| Short-code guessing to bind victims' pending devices | Tampering | 31⁸ space, 5 min TTL, user-api bucket. Atomic `pending`-only confirm with `user_id IS NULL` |
+| Revoked device keeps refreshing | Elevation | JTI follows refresh (§Q7) |
+| Trusted-device token theft | Spoofing | SHA-256 at rest, 30 d expiry, revoke on password change and last-method disable, per-user listing |
+| Email downgrade of TOTP users | Spoofing | Email only when enabled (A8) |
+| Mail bombing via `email/send` | DoS | Bucket + per-challenge send cap |
+| Admin reset abuse | Elevation | New permission, Danger confirm, cabana audit log (`cabana: admin record action`) |
+| Restoring a deactivated account with the password only | Tampering | Pitfall 21 / A10 decision |
+
+## Sources
+
+### Primary (HIGH confidence)
+- PHP reference, read this session: `/media/nvme/dev/jz/wavepath.org/plugins/golem15/user/{routes.php, controllers/ApiController.php, controllers/TwoFactorApiController.php, classes/twofactor/*.php, models/{TwoFactorChallenge,TwoFactorMethod,TwoFactorRecoveryCode,TrustedDevice,WebAuthnCredential,DeviceAuthSession,Settings}.php, models/settings/fields.yaml, views/mail/two_factor_code.htm, updates/v2.1.0-v3.1.0, components/TwoFactor.php, components/DeviceAuth.php, tests/security/AuthenticationTest.php}`, `vendor/lbuchs/webauthn/src/WebAuthn.php`
+- Clients: `vue-queststream-app/composables/useDeviceAuth.ts`, `vue-wavepath-app/src/types/auth.ts:157-162`, `vue-fonoteka-app/app/stores/auth.ts:328`
+- Go, read this session: sm-user-plugin v0.1.5 (`plugin.go`, `routes.go`, `controllers/{api_controller,registration,users_admin_controller}.go`, `controllers/users/config_form.yaml`, `classes/{events,throttle,mail,oauth_providers,api_tokens}.go`, `updates/*`); sm-sociallogin-plugin (`plugin.go`, `routes.go`, `admin_settings.go`, `models/settings.go`, `classes/{flow,codes}.go`, `controllers/{complete,helpers}.go`); summercms.go modules `bouncer/{mint,refresh,jwt,password}.go`, `cabana/{extension,actions,registry,settings,form_schema,field_taglist}.go`, `pact/capabilities.go`, `postcard/templates.go`, `festival/bus.go`, `cooler/cooler.go`, `surf/{limiter,router,accesslog}.go`, `wire/response.go`, `lagoon/encrypted.go`; `fonoteka/phase07_coverage_test.go`
+- Module sources: `go-webauthn/webauthn@v0.18.2` (`webauthn/*.go`, `protocol/*.go`, `doc.go`, `example_passkey_test.go`), `pquerna/otp@v1.5.0` (`totp/totp.go`, `hotp/hotp.go`, `go.mod`), `boombuler/barcode@v1.1.0`, `piglig/go-qr@v1.1.0`
+- Measurements: proxy.golang.org `go list -m -json`, `gh api repos/*`, probe builds (size, `go version -m`), `govulncheck@v1.9.0`, QR round-trip, bcrypt timing
+
+### Secondary (MEDIUM confidence)
+- [W3C public-webauthn: restrict to valid domains](https://lists.w3.org/Archives/Public/public-webauthn/2017May/0250.html)
+- [Ory: passkeys WebAuthn SecurityError](https://ory.sh/docs/troubleshooting/passkeys-webauthn-security-error)
+- [piglig/go-qr repository](https://github.com/piglig/go-qr)
+
+### Tertiary (LOW confidence)
+- Carbon `toJSON` microsecond format, `@simplewebauthn/browser` option shape, Google2FA default secret length (training knowledge, marked [ASSUMED])
+
+## Metadata
+
+**Confidence breakdown:**
+- Standard stack: HIGH. Versions from the proxy, size and graph measured, vulns scanned, QR proven by round-trip.
+- Architecture: HIGH. Every seam traced to Go code read this session; the refresh-lineage and cabana-action gaps verified in source.
+- Pitfalls: HIGH for PHP behaviour and races (read in source), MEDIUM for browser WebAuthn origin behaviour (cited, not executed).
+
+**Research date:** 2026-10-21
+**Valid until:** 2026-11-20 (go-webauthn releases often and is pre-1.0; re-check the pin before `go get` if later)
diff --git a/.planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-VALIDATION.md b/.planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-VALIDATION.md
new file mode 100644
index 00000000..c088fff9
--- /dev/null
+++ b/.planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-VALIDATION.md
@@ -0,0 +1,77 @@
+---
+phase: "19.6"
+slug: "two-factor-auth-and-device-flow-in-sm-user-plugin"
+# status lifecycle: draft (seeded by plan-phase) → validated (set by validate-phase §6)
+# audit-milestone §5.5 distinguishes NOT-VALIDATED (draft) from PARTIAL (validated + nyquist_compliant: false) (#2117)
+status: draft
+nyquist_compliant: false
+wave_0_complete: false
+created: "2026-10-21"
+---
+
+# Phase 19.6 — Validation Strategy
+
+> Per-phase validation contract for feedback sampling during execution.
+
+---
+
+## Test Infrastructure
+
+| Property | Value |
+|----------|-------|
+| **Framework** | Go `testing` + `httptest`; testcontainers-go Postgres (DB tests skip under `-short`) |
+| **Config file** | none — Wave 0 adds `sm-mfa-plugin/testmain_test.go` and `harness_test.go` (copied from `sm-sociallogin-plugin`) |
+| **Quick run command** | `go -C ../sm-mfa-plugin test -short ./...` and `go -C ../fonoteka.go test -short ./plugins/golem15/user/...` |
+| **Full suite command** | `scripts/check-phase19.6.sh` (go, userplugin, mfa, sociallogin, demo, bm, fonoteka, docker, docs stages) |
+| **Estimated runtime** | ~180 seconds (full, with Docker) |
+
+---
+
+## Sampling Rate
+
+- **After every task commit:** `go vet ./...` + `go test -short ./...` of the touched repo
+- **After every plan wave:** full suites (Docker) of every touched repo
+- **Before `/msd-verify-work`:** `scripts/check-phase19.6.sh` must be green
+- **Max feedback latency:** 60 seconds for the short loop
+
+---
+
+## Per-Task Verification Map
+
+Decision → test map is in `19.6-RESEARCH.md` § Validation Architecture; the planner assigns task IDs.
+
+| Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status |
+|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------|
+| 19.6-XX-XX | TBD | TBD | D-XX | T-19.6-XX | per RESEARCH map | unit/integration | per RESEARCH map | ❌ W0 | ⬜ pending |
+
+*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
+
+---
+
+## Wave 0 Requirements
+
+- [ ] `sm-mfa-plugin/testmain_test.go`, `harness_test.go` — boot `golem15.user` + `golem15.mfa`
+- [ ] WebAuthn fixtures — recorded attestation/assertion JSON or a virtual authenticator
+- [ ] Clock injection for TOTP tests
+- [ ] user-plugin harness helper for device-flow tests
+
+---
+
+## Manual-Only Verifications
+
+| Behavior | Requirement | Why Manual | Test Instructions |
+|----------|-------------|------------|-------------------|
+| WebAuthn registration and login in a real browser | D-08 / SC-1 | Browser authenticator ceremony cannot run in `go test` | Register a passkey on the demo instance over HTTPS with a hostname RP ID, then sign in with it |
+
+---
+
+## Validation Sign-Off
+
+- [ ] All tasks have `` verify or Wave 0 dependencies
+- [ ] Sampling continuity: no 3 consecutive tasks without automated verify
+- [ ] Wave 0 covers all MISSING references
+- [ ] No watch-mode flags
+- [ ] Feedback latency < 60s
+- [ ] `nyquist_compliant: true` set in frontmatter
+
+**Approval:** pending