{"_id":"@alluxi/mfa","_rev":"7-bc15d2d573978bf90dcf30e5436566ca","name":"@alluxi/mfa","dist-tags":{"latest":"0.3.0"},"versions":{"0.1.0":{"name":"@alluxi/mfa","version":"0.1.0","keywords":["alluxi","axionix","mfa","2fa","webauthn","passkey","totp"],"license":"MIT","_id":"@alluxi/mfa@0.1.0","maintainers":[{"name":"gusreyes01","email":"gustavo@alluxi.com"}],"dist":{"shasum":"9fb2108589f1bcb7d183534db2d31b2a1301a186","tarball":"https://registry.npmjs.org/@alluxi/mfa/-/mfa-0.1.0.tgz","fileCount":11,"integrity":"sha512-hs0QW5CRgo3gA1ErIKLQWAHT86b7ZsGEYyq40hMQkiInHjib7LP65vgvOJWcO1eoDmc1r/fr+1pdyrFSXoV5/w==","signatures":[{"sig":"MEQCIFrOLdG433TLHdMo5mLuRBTZI4MVLmRmjbh7JvOxnCtpAiByVObf1nxPb0d2xNvLBuNt12ziit5LTd/NfvJYasqn/Q==","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":129014},"main":"./dist/index.js","type":"module","types":"./dist/index.d.ts","module":"./dist/index.js","exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.js"},"./browser":{"types":"./dist/browser.d.ts","import":"./dist/browser.js"}},"scripts":{"dev":"tsup --watch","test":"vitest run","build":"tsup","migrate":"node scripts/migrate.mjs","typecheck":"tsc --noEmit","prepublishOnly":"npm run build && npm test"},"_npmUser":{"name":"gusreyes01","email":"gustavo@alluxi.com"},"_npmVersion":"11.6.2","description":"Alluxi/Axionix shared two-factor authentication — passkeys, TOTP, recovery codes and cross-app device trust","directories":{},"_nodeVersion":"22.14.0","dependencies":{"pg":"^8.13.1","otplib":"^13.4.1","qrcode":"^1.5.4","@simplewebauthn/server":"^13.3.2","@simplewebauthn/browser":"^13.3.0"},"_hasShrinkwrap":false,"devDependencies":{"tsup":"^8","vitest":"^3","@types/pg":"^8.11.10","typescript":"^5","@types/node":"^22","@types/qrcode":"^1.5.5"},"_npmOperationalInternal":{"tmp":"tmp/mfa_0.1.0_1785266167185_0.73733159551634","host":"s3://npm-registry-packages-npm-production"}},"0.1.1":{"name":"@alluxi/mfa","version":"0.1.1","keywords":["alluxi","axionix","mfa","2fa","webauthn","passkey","totp"],"license":"MIT","_id":"@alluxi/mfa@0.1.1","maintainers":[{"name":"gusreyes01","email":"gustavo@alluxi.com"}],"dist":{"shasum":"21c32f717c3ca743f6fbb0621d94596a7dd11a2c","tarball":"https://registry.npmjs.org/@alluxi/mfa/-/mfa-0.1.1.tgz","fileCount":11,"integrity":"sha512-RIV0amlttaUcBLVTuFBJ0eqRKOAweKPoSIIU6t2l6u8DIrVomIi7LWrKHN+rTqR340HEQthcLD/XMQffGlXqvA==","signatures":[{"sig":"MEUCIQDbWwAxxjWcqCHh3HqcqAD6Ji/jfPHYbXmeYrCG93Zi1gIgUvk2LcQQV1PBbt3U49ZcJ0cZ0ypxn6XpK2ifUp/H1XU=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":132380},"main":"./dist/index.js","type":"module","types":"./dist/index.d.ts","module":"./dist/index.js","exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.js"},"./browser":{"types":"./dist/browser.d.ts","import":"./dist/browser.js"}},"scripts":{"dev":"tsup --watch","test":"vitest run","build":"tsup","migrate":"node scripts/migrate.mjs","typecheck":"tsc --noEmit","prepublishOnly":"npm run build && npm test"},"_npmUser":{"name":"gusreyes01","email":"gustavo@alluxi.com"},"_npmVersion":"11.6.2","description":"Alluxi/Axionix shared two-factor authentication — passkeys, TOTP, recovery codes and cross-app device trust","directories":{},"_nodeVersion":"22.14.0","dependencies":{"pg":"^8.13.1","otplib":"^13.4.1","qrcode":"^1.5.4","@simplewebauthn/server":"^13.3.2","@simplewebauthn/browser":"^13.3.0"},"_hasShrinkwrap":false,"devDependencies":{"tsup":"^8","vitest":"^3","@types/pg":"^8.11.10","typescript":"^5","@types/node":"^22","@types/qrcode":"^1.5.5"},"_npmOperationalInternal":{"tmp":"tmp/mfa_0.1.1_1785268133922_0.11194155677756235","host":"s3://npm-registry-packages-npm-production"}},"0.1.2":{"name":"@alluxi/mfa","version":"0.1.2","keywords":["alluxi","axionix","mfa","2fa","webauthn","passkey","totp"],"license":"MIT","_id":"@alluxi/mfa@0.1.2","maintainers":[{"name":"gusreyes01","email":"gustavo@alluxi.com"}],"dist":{"shasum":"533ad63e4186bb8fd976709325715b6f273fc105","tarball":"https://registry.npmjs.org/@alluxi/mfa/-/mfa-0.1.2.tgz","fileCount":11,"integrity":"sha512-F+Jyj366gWYATUPM/zEz0NMVxNVQXk60sc4AetsWS0OcHo3yQ7hjdzadnEnu8F752urPlMEZMbIBN+Kxp/c0vg==","signatures":[{"sig":"MEUCIQCs6WRVgpg/Pgj5LBFWxNUJ3j0cDUxwbA3tUDrgEu7X/wIgSb7m4C94uYialnuMLbrfT+vr7N10Yd19vJdSxTazSEo=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":133543},"main":"./dist/index.js","type":"module","types":"./dist/index.d.ts","module":"./dist/index.js","exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.js"},"./browser":{"types":"./dist/browser.d.ts","import":"./dist/browser.js"}},"scripts":{"dev":"tsup --watch","test":"vitest run","build":"tsup","migrate":"node scripts/migrate.mjs","typecheck":"tsc --noEmit","prepublishOnly":"npm run build && npm test"},"_npmUser":{"name":"gusreyes01","email":"gustavo@alluxi.com"},"_npmVersion":"11.6.2","description":"Alluxi/Axionix shared two-factor authentication — passkeys, TOTP, recovery codes and cross-app device trust","directories":{},"_nodeVersion":"22.14.0","dependencies":{"pg":"^8.13.1","otplib":"^13.4.1","qrcode":"^1.5.4","@simplewebauthn/server":"^13.3.2","@simplewebauthn/browser":"^13.3.0"},"_hasShrinkwrap":false,"devDependencies":{"tsup":"^8","vitest":"^3","@types/pg":"^8.11.10","typescript":"^5","@types/node":"^22","@types/qrcode":"^1.5.5"},"_npmOperationalInternal":{"tmp":"tmp/mfa_0.1.2_1785269065598_0.41883474522255426","host":"s3://npm-registry-packages-npm-production"}},"0.1.3":{"name":"@alluxi/mfa","version":"0.1.3","keywords":["alluxi","axionix","mfa","2fa","webauthn","passkey","totp"],"license":"MIT","_id":"@alluxi/mfa@0.1.3","maintainers":[{"name":"gusreyes01","email":"gustavo@alluxi.com"}],"dist":{"shasum":"1bafc86cc7057b7727cf64595f1a95d0ecf3728a","tarball":"https://registry.npmjs.org/@alluxi/mfa/-/mfa-0.1.3.tgz","fileCount":11,"integrity":"sha512-fQ/V7cJtTUjjna3h1t19kez5Ir15LQQhMg6ylhrYZAJbTA+o88rN7fFEewiGOS00dZ+iqU2Un1EmjSPbOElynw==","signatures":[{"sig":"MEYCIQDDBwQqeCZ59Uk1DjpdfBBN86/aeRWeZj5GI20knM7JnQIhALjdkGKrteqpsnPMkwQiDbUxkxMvRB/j86BhZzotitWU","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":143958},"main":"./dist/index.js","type":"module","types":"./dist/index.d.ts","module":"./dist/index.js","exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.js"},"./browser":{"types":"./dist/browser.d.ts","import":"./dist/browser.js"}},"scripts":{"dev":"tsup --watch","test":"vitest run","build":"tsup","migrate":"node scripts/migrate.mjs","typecheck":"tsc --noEmit","prepublishOnly":"npm run build && npm test"},"_npmUser":{"name":"gusreyes01","email":"gustavo@alluxi.com"},"_npmVersion":"11.6.2","description":"Alluxi/Axionix shared two-factor authentication — passkeys, TOTP, recovery codes and cross-app device trust","directories":{},"_nodeVersion":"22.14.0","dependencies":{"pg":"^8.13.1","otplib":"^13.4.1","qrcode":"^1.5.4","@simplewebauthn/server":"^13.3.2","@simplewebauthn/browser":"^13.3.0"},"_hasShrinkwrap":false,"devDependencies":{"tsup":"^8","vitest":"^3","@types/pg":"^8.11.10","typescript":"^5","@types/node":"^22","@types/qrcode":"^1.5.5"},"_npmOperationalInternal":{"tmp":"tmp/mfa_0.1.3_1785336335994_0.6616117911295645","host":"s3://npm-registry-packages-npm-production"}},"0.1.4":{"name":"@alluxi/mfa","version":"0.1.4","keywords":["alluxi","axionix","mfa","2fa","webauthn","passkey","totp"],"license":"MIT","_id":"@alluxi/mfa@0.1.4","maintainers":[{"name":"gusreyes01","email":"gustavo@alluxi.com"}],"dist":{"shasum":"38376341aa1b0578842f8f3a4d3f253b8c26b4ba","tarball":"https://registry.npmjs.org/@alluxi/mfa/-/mfa-0.1.4.tgz","fileCount":11,"integrity":"sha512-Wg2DpSedDTp2bdYhOtcVhTpH2hf4tO0UVbzpl5bYoplr2fd6tdUN4v806DeuW/2q9omsHIHtiq6Vs0dE6uETiw==","signatures":[{"sig":"MEUCIQC0COXmQxGhKXqYBslys+RCqVbadRZhPf0XCyr7MYrfqgIgQamw/oVRE0ooQxn4Bx29IZHcXdfaAkJOveSqX9arNH4=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":145727},"main":"./dist/index.js","type":"module","types":"./dist/index.d.ts","module":"./dist/index.js","exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.js"},"./browser":{"types":"./dist/browser.d.ts","import":"./dist/browser.js"}},"scripts":{"dev":"tsup --watch","test":"vitest run","build":"tsup","migrate":"node scripts/migrate.mjs","typecheck":"tsc --noEmit","prepublishOnly":"npm run build && npm test"},"_npmUser":{"name":"gusreyes01","email":"gustavo@alluxi.com"},"_npmVersion":"11.6.2","description":"Alluxi/Axionix shared two-factor authentication — passkeys, TOTP, recovery codes and cross-app device trust","directories":{},"_nodeVersion":"22.14.0","dependencies":{"pg":"^8.13.1","otplib":"^13.4.1","qrcode":"^1.5.4","@simplewebauthn/server":"^13.3.2","@simplewebauthn/browser":"^13.3.0"},"_hasShrinkwrap":false,"devDependencies":{"tsup":"^8","vitest":"^3","@types/pg":"^8.11.10","typescript":"^5","@types/node":"^22","@types/qrcode":"^1.5.5"},"_npmOperationalInternal":{"tmp":"tmp/mfa_0.1.4_1785342486006_0.22674177358973768","host":"s3://npm-registry-packages-npm-production"}},"0.2.0":{"name":"@alluxi/mfa","version":"0.2.0","keywords":["alluxi","axionix","mfa","2fa","webauthn","passkey","totp"],"license":"MIT","_id":"@alluxi/mfa@0.2.0","maintainers":[{"name":"gusreyes01","email":"gustavo@alluxi.com"}],"dist":{"shasum":"89b17ee3b326e98257376baea9be5b680a430f9e","tarball":"https://registry.npmjs.org/@alluxi/mfa/-/mfa-0.2.0.tgz","fileCount":12,"integrity":"sha512-ZGAmAmf33Xl6WMW2+MwGT5MengIJaH9tBCllcqK4bHPE3dAWMvZB0ju0ONN8qJeooGAu3UdfqcP7M1xqYPvZOQ==","signatures":[{"sig":"MEQCIFvWSp4TmjQ4y+H5Rg0QqNg1zzsWVHK0Vtaddzw9EvwpAiBrIY8+JQkKVOvWwoH+fBBPWiuPUX6zAWSL3+MKuytA3w==","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":197309},"main":"./dist/index.js","type":"module","types":"./dist/index.d.ts","module":"./dist/index.js","exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.js"},"./browser":{"types":"./dist/browser.d.ts","import":"./dist/browser.js"}},"gitHead":"492d87a79ee8404a810be5a181255b95ee2eb5bd","scripts":{"dev":"tsup --watch","test":"vitest run","build":"tsup","migrate":"node scripts/migrate.mjs","typecheck":"tsc --noEmit","prepublishOnly":"npm run build && npm test"},"_npmUser":{"name":"gusreyes01","email":"gustavo@alluxi.com"},"_npmVersion":"11.6.2","description":"Alluxi/Axionix shared two-factor authentication — passkeys, TOTP, emailed backup codes, recovery codes and cross-app device trust","directories":{},"_nodeVersion":"22.14.0","dependencies":{"pg":"^8.13.1","otplib":"^13.4.1","qrcode":"^1.5.4","resend":"^6.18.1","@simplewebauthn/server":"^13.3.2","@simplewebauthn/browser":"^13.3.0"},"_hasShrinkwrap":false,"devDependencies":{"tsup":"^8","vitest":"^3","@types/pg":"^8.11.10","typescript":"^5","@types/node":"^22","@types/qrcode":"^1.5.5"},"_npmOperationalInternal":{"tmp":"tmp/mfa_0.2.0_1785861165330_0.03616056237588894","host":"s3://npm-registry-packages-npm-production"}},"0.3.0":{"name":"@alluxi/mfa","version":"0.3.0","description":"Alluxi/Axionix shared two-factor authentication — passkeys, TOTP, emailed backup codes, recovery codes and cross-app device trust","license":"MIT","keywords":["alluxi","axionix","mfa","2fa","webauthn","passkey","totp"],"type":"module","main":"./dist/index.js","module":"./dist/index.js","types":"./dist/index.d.ts","exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.js"},"./browser":{"types":"./dist/browser.d.ts","import":"./dist/browser.js"}},"scripts":{"build":"tsup","dev":"tsup --watch","migrate":"node scripts/migrate.mjs","prepublishOnly":"npm run build && npm test","test":"vitest run","typecheck":"tsc --noEmit"},"dependencies":{"@simplewebauthn/browser":"^13.3.0","@simplewebauthn/server":"^13.3.2","otplib":"^13.4.1","pg":"^8.13.1","qrcode":"^1.5.4","resend":"^6.18.1"},"devDependencies":{"@types/node":"^22","@types/pg":"^8.11.10","@types/qrcode":"^1.5.5","tsup":"^8","typescript":"^5","vitest":"^3"},"gitHead":"492d87a79ee8404a810be5a181255b95ee2eb5bd","_id":"@alluxi/mfa@0.3.0","_nodeVersion":"22.14.0","_npmVersion":"11.6.2","dist":{"integrity":"sha512-5sjXDJZrIJaJpNkcNGB0JjaQ9+CJbD7uOAmWd6qQ9qoTX55zRKnLYbpKw1UAa0cgHxltjvS9XOSG3CwbTlM4qA==","shasum":"25e28a09d0cd5bcae5a5a91eb860a5028f30e59b","tarball":"https://registry.npmjs.org/@alluxi/mfa/-/mfa-0.3.0.tgz","fileCount":12,"unpackedSize":203169,"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEQCIFR6A0lWlGi4D7J1AQwQMkMkXtXpU0J/69HyfrKBC2/gAiAhAbkcOUL3r+/YnH800pssF5LD6P0AxXebnOgEdzTsSQ=="}]},"_npmUser":{"name":"gusreyes01","email":"gustavo@alluxi.com"},"directories":{},"maintainers":[{"name":"gusreyes01","email":"gustavo@alluxi.com"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/mfa_0.3.0_1785863167380_0.7304456721810151"},"_hasShrinkwrap":false}},"time":{"created":"2026-07-28T19:16:07.005Z","modified":"2026-08-04T17:06:07.686Z","0.1.0":"2026-07-28T19:16:07.322Z","0.1.1":"2026-07-28T19:48:54.070Z","0.1.2":"2026-07-28T20:04:25.735Z","0.1.3":"2026-07-29T14:45:36.155Z","0.1.4":"2026-07-29T16:28:06.146Z","0.2.0":"2026-08-04T16:32:45.474Z","0.3.0":"2026-08-04T17:06:07.495Z"},"license":"MIT","keywords":["alluxi","axionix","mfa","2fa","webauthn","passkey","totp"],"description":"Alluxi/Axionix shared two-factor authentication — passkeys, TOTP, emailed backup codes, recovery codes and cross-app device trust","maintainers":[{"name":"gusreyes01","email":"gustavo@alluxi.com"}],"readme":"# @alluxi/mfa\n\nShared two-factor authentication for the Axionix suite: Chrome/platform **passkeys**\n(WebAuthn) and **authenticator apps** (TOTP), with single-use recovery codes, an\n**emailed backup code** for lost devices, and cross-app device trust.\n\nA user enrolls **once**. All eight tools — `admin`, `bdr`, `compliance`, `gov`, `hr`,\n`otto`, `pm`, `v3` — honor that enrollment.\n\n## How \"enroll once\" works\n\nTwo things make it possible:\n\n1. **One shared store.** All MFA records live in an `mfa` schema in the shared Neon\n   database, keyed on lowercased **email**. Not on a user id — users are provisioned\n   separately in each tool and `axionix-gov` has no user table at all, so email is the\n   only identity spanning the suite.\n2. **One Relying Party ID.** Every tool is served from `*.alluxi.com`, so the WebAuthn\n   RP ID is the registrable parent `alluxi.com`. A passkey created at `time.alluxi.com`\n   is valid at `pm.alluxi.com` with no re-enrollment.\n\n   > Passkeys are bound to the RP ID. Reaching an app by its `*.vercel.app` hostname\n   > instead of its `*.alluxi.com` domain will reject them.\n\nVerification is tracked separately from enrollment: each app's own session carries the\nresult, and a `.alluxi.com`-scoped signed cookie means clearing the challenge in one tool\nsatisfies the rest for `MFA_TRUSTED_DEVICE_DAYS` (default 30). The cookie is HMAC-signed\n*and* backed by a database row, so a lost laptop can be cut off immediately rather than\nwaiting for expiry.\n\n## Install\n\n```bash\nnpm install @alluxi/mfa          # or: file:../axionix-mfa for local development\n```\n\nApply the schema once per database (idempotent, safe to re-run):\n\n```bash\nMFA_DATABASE_URL=\"postgresql://…?schema=mfa\" npm run migrate --prefix ../axionix-mfa\n```\n\nThe tables sit outside every app's Prisma schema on purpose: `gov` uses Drizzle and could\nnever share a Prisma client, and folding them into `v3` would force `multiSchema` — and an\n`@@schema` annotation on all ~40 existing models — onto that app.\n\n## Environment\n\n| Variable | Required | Notes |\n|---|---|---|\n| `MFA_DATABASE_URL` | yes | Shared Neon URL with `?schema=mfa` |\n| `MFA_ENCRYPTION_KEY` | yes | 32 bytes, hex or base64. `openssl rand -hex 32`. Encrypts TOTP secrets at rest |\n| `MFA_SESSION_SECRET` | yes | ≥32 chars. **Must be byte-identical across all eight apps** |\n| `MFA_RP_ID` | prod | `alluxi.com` in production, `localhost` in development (default) |\n| `MFA_RP_ORIGIN` | no | Comma-separated allowed origins. Defaults to `https://<rp-id>`, or ports 3000-3006 on localhost |\n| `MFA_RP_NAME` | no | Shown in the passkey prompt. Default `Alluxi` |\n| `MFA_ENFORCE` | no | `true` turns on the gate. Default `false` |\n| `MFA_GRACE_DAYS` | no | Days to enroll before the hard gate. Default `7` |\n| `MFA_TRUSTED_DEVICE_DAYS` | no | Default `30` |\n| `MFA_RESEND_API_KEY` | email | Falls back to the app's own `RESEND_API_KEY` |\n| `MFA_MAIL_FROM` | email | Falls back to the app's own `RESEND_FROM_EMAIL` |\n\nThe two mail variables are only read when someone actually uses an emailed backup code, so\nseven of the eight apps need no new configuration — they already carry Resend credentials.\n`axionix-compliance` ships no mailer of its own and needs `RESEND_API_KEY` and\n`RESEND_FROM_EMAIL` (or the `MFA_`-prefixed pair) added before the backup route works there.\n\nA mismatched `MFA_SESSION_SECRET` degrades silently to re-prompting rather than erroring —\nworth asserting at startup.\n\n## Server usage\n\n```ts\nimport { evaluateMfa, blocksAccess, TRUSTED_DEVICE_COOKIE } from \"@alluxi/mfa\";\n\nconst state = await evaluateMfa({\n  email: session.user.email,\n  trustedDeviceCookie: cookies().get(TRUSTED_DEVICE_COOKIE)?.value,\n  sessionVerified: token.mfa === \"ok\",   // skips the DB round-trip\n});\n\nif (blocksAccess(state.status)) redirect(\"/mfa\");\n```\n\n`state.status` is one of:\n\n| Status | Meaning | Gates? |\n|---|---|---|\n| `ok` | Second factor satisfied | no |\n| `challenge` | Enrolled, but this session/device hasn't proven it | **yes** |\n| `enroll` | Nothing registered and grace has expired | **yes** |\n| `grace` | Nothing registered, still inside the window — show the nag banner | no |\n| `off` | Enforcement disabled and nothing registered | no |\n\nEnrolled users are always challenged, even with `MFA_ENFORCE=false`: turning enforcement\noff must never silently weaken an account that already has a second factor.\n\n**Non-interactive callers must bypass this** — `axk_` personal access tokens and cron\nroutes authenticate as machines and have no way to answer a challenge.\n\n## Client usage\n\n```ts\nimport { registerPasskey, authenticateWithPasskey, verifyTotpCode } from \"@alluxi/mfa/browser\";\n```\n\nThese post to the `/api/mfa/*` routes each app mounts.\n\n## Recovery\n\nTen single-use codes are issued at enrollment and shown **exactly once**; only SHA-256\nhashes are stored. `resetMfa(email)` clears every factor for a locked-out user and is what\nan admin \"reset MFA\" action should call.\n\n### Emailed backup codes\n\nA user may confirm an address that can be mailed a 6-digit code at the challenge screen —\nthe self-service way out when both the device and the written-down recovery codes are gone.\n\n**It is not a standalone factor and cannot be enrolled with.** Sign-in across the suite is\nGoogle OAuth on the same address, so a code delivered to the account mailbox proves nothing\nthe OAuth session did not already prove; treating it as a factor would be a gate that\nunlocks itself. `evaluateMfa` therefore reports `factors.email` but excludes it from\n`enrolled`. Pointing the backup at a *different* mailbox — which `email/setup` accepts and\nthe UI recommends — is what makes it genuinely independent.\n\nThe split across the gate matters:\n\n| Endpoint | Reachable while gated | Why |\n|---|---|---|\n| `email/send` | yes | Only ever mails the address already confirmed on the account |\n| `email/verify` | yes | It is how someone proves the factor |\n| `email/setup` | **no** | Chooses the destination; a hijacked, unverified session could otherwise redirect the gate to its own mailbox |\n| `email/disable` | **no** | Factor management |\n\nCodes live 10 minutes, are stored as SHA-256 hashes, allow 5 guesses each, and a resend is\nrefused inside a 30-second cooldown. Verification runs through `verifySecondFactor`, so the\nsuite-wide 10-failures-per-15-minutes lockout covers it too. Unlike the other methods,\n`email/verify` defaults `remember` to **false**: someone reaching for the backup has usually\njust lost a device, which is the wrong moment to grant a 30-day pass by default.\n\n## Tests\n\n```bash\nnpm test\n```\n\nCovers TOTP drift windows, recovery-code reuse, tampered/expired/cross-account trust\ncookies, emailed-code expiry/replay/attempt caps, and the grace-period boundaries. No\ndatabase required — the store layer is mocked, and the mailer never sends.\n","readmeFilename":"README.md"}