feat(auth): unique, changeable, verifiable email addresses (engagement Phase 1b)
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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user