feat(auth): unique, changeable, verifiable email addresses (engagement Phase 1b)
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 29s
PR Checks / client-build (pull_request) Successful in 31s
PR Checks / server-tests (pull_request) Successful in 10m34s

Makes `users.email` unique, de-duplicates the addresses an upgrade will find,
and builds the self-service change-and-verify flow that did not exist.

The uniqueness index is on a generated `email_norm AS (LOWER(email)) STORED`
column under `utf8mb4_bin`, NOT on `email` under a `_ci` collation as the plan
specified. Every case-insensitive collation this server offers is also
accent-insensitive: `josé@x.com` and `jose@x.com` compare equal, and those are
two different mailboxes. The plan's index would have refused the second address
forever and the de-duplication would have nulled a legitimate account's.

A requested address is STAGED in `email_pending` and only a tokened link
installs it, so a typo cannot silently redirect account-recovery mail.

`isDuplicateUsername()` now distinguishes the two indexes. All five call sites
branch on it; each answers differently on purpose, because a public form, an
IdP callback, a half-completed invite and an admin screen do not owe the same
person the same amount of truth.

SSO reads the IdP's actual `email_verified`/`verified` claim instead of
inferring verification from an address merely being present.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-29 01:53:50 -05:00
parent c2e4df5b3d
commit fbb4b0bd91
44 changed files with 3024 additions and 59 deletions

View File

@@ -67,6 +67,30 @@ function registrationFlags(mode) {
}
}
// Engagement Phase 1b — may an UNVERIFIED address receive opt-in engagement mail?
// Stored as 'on'/'off'. Seeded by schema.sql ASYMMETRICALLY on purpose: 'on' for a
// fresh install, 'off' for an upgrade. Turning it on retroactively would silently
// stop mailing every already-opted-in user on the day the operator upgraded, which
// is the G22 mistake — a safe default must not be applied backwards to a running
// system without telling anyone.
//
// Nothing CONSUMES this yet: the engine that would honour it arrives in Phase 4
// and the deliverability rules in Phase 9. It is seeded and editable here because
// the fresh-vs-upgrade distinction is only knowable at the migration that adds it,
// and reconstructing "was this install fresh?" later is guesswork.
const EMAIL_VERIFICATION_KEY = 'email_verification_required'
// Fail-safe direction is 'off': an unreadable or missing value must not silently
// suppress mail an operator believes is going out. The loud failure mode (mail
// reaching an unverified address) is recoverable; the quiet one is not.
async function isEmailVerificationRequired() {
try {
return String(await settingsDb.get(EMAIL_VERIFICATION_KEY)) === 'on'
} catch {
return false
}
}
// Android App Links opt-in (M9 follow-up). When on, the shard auto-serves
// /.well-known/assetlinks.json and the mobile SSO bridge additionally accepts the
// self-origin https://<host>/mobile/callback redirect. Stored as the string
@@ -256,6 +280,8 @@ module.exports = {
REGISTRATION_KEY,
REGISTRATION_MODES,
getRegistrationMode,
EMAIL_VERIFICATION_KEY,
isEmailVerificationRequired,
registrationFlags,
MOBILE_APP_LINKS_KEY,
isMobileAppLinksEnabled,